Spaces:
Running
Running
github-actions[bot] commited on
Commit Β·
415fa07
1
Parent(s): ba81ab3
Deploy 8279a26
Browse filesA named cut site answers a click
Source: https://github.com/WINTER4000/turingDNA/commit/8279a26cf5026404a3528af335d9946bfa2187fd
- dee/static/app.css +15 -0
- dee/static/app.js +86 -6
- dee/static/index.html +5 -5
- tests/test_plasmid_editor_ui.py +61 -0
dee/static/app.css
CHANGED
|
@@ -8345,6 +8345,21 @@ body[data-pe-full="1"] .pe-fullbar { display: flex; }
|
|
| 8345 |
.plasmid-map-wrap[data-panning="1"] { user-select: none; }
|
| 8346 |
.pm-feat:hover, .pm-feat-rect:hover { filter: brightness(1.25); }
|
| 8347 |
.pm-feat-lbl:hover { text-decoration: underline; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8348 |
|
| 8349 |
@media (max-width: 900px) {
|
| 8350 |
/* Stack, and let each pane keep its own scroll. Side-by-side below ~900px
|
|
|
|
| 8345 |
.plasmid-map-wrap[data-panning="1"] { user-select: none; }
|
| 8346 |
.pm-feat:hover, .pm-feat-rect:hover { filter: brightness(1.25); }
|
| 8347 |
.pm-feat-lbl:hover { text-decoration: underline; }
|
| 8348 |
+
/* Cut marks answer a click (showCutSite), so they have to say so. The tick is
|
| 8349 |
+
1px wide β far too thin to aim at β and `stroke-width` would make it look
|
| 8350 |
+
heavier, so the hit area is widened invisibly instead. */
|
| 8351 |
+
.pm-cut, .pm-cut-lbl { cursor: pointer; }
|
| 8352 |
+
.pm-cut { stroke-linecap: round; }
|
| 8353 |
+
.pm-cut:hover, .pm-cut-lbl:hover { stroke: var(--ink); color: var(--ink); }
|
| 8354 |
+
.pm-cut-lbl:hover { text-decoration: underline; }
|
| 8355 |
+
.pm-cut { pointer-events: stroke; stroke-width: 1; }
|
| 8356 |
+
.plasmid-map-wrap .pm-cut { paint-order: stroke; }
|
| 8357 |
+
.seqcut { cursor: pointer; }
|
| 8358 |
+
.seqcut:hover b { color: var(--ink); text-decoration: underline; }
|
| 8359 |
+
.seqcut:hover .seqcut-tick { background: var(--ink); }
|
| 8360 |
+
/* The positions printed under the pane are jumps to each remaining site. */
|
| 8361 |
+
.seq-cut-jump { color: var(--brand); text-decoration: none; cursor: pointer; }
|
| 8362 |
+
.seq-cut-jump:hover { text-decoration: underline; }
|
| 8363 |
|
| 8364 |
@media (max-width: 900px) {
|
| 8365 |
/* Stack, and let each pane keep its own scroll. Side-by-side below ~900px
|
dee/static/app.js
CHANGED
|
@@ -8734,7 +8734,7 @@ function runOracle(opts){
|
|
| 8734 |
// Tick marks first, under every label.
|
| 8735 |
cutInfo.shown.forEach((c) => {
|
| 8736 |
const [x1, y1] = ptAt(c.pos, len, R - 12, cx, cy), [x2, y2] = ptAt(c.pos, len, R + 20, cx, cy);
|
| 8737 |
-
svg += `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" class="pm-cut"/>`;
|
| 8738 |
});
|
| 8739 |
items.forEach((p) => {
|
| 8740 |
const anchor = p.right ? 'start' : 'end';
|
|
@@ -8748,6 +8748,7 @@ function runOracle(opts){
|
|
| 8748 |
+ `dominant-baseline="middle">${esc(p.f.name)}</text>`;
|
| 8749 |
} else {
|
| 8750 |
svg += `<text x="${tx.toFixed(1)}" y="${p.ly.toFixed(1)}" class="pm-cut-lbl" `
|
|
|
|
| 8751 |
+ `text-anchor="${anchor}" dominant-baseline="middle">${esc(p.c.name)}`
|
| 8752 |
+ `<title>${esc(p.c.name)} Β· cuts at ${p.c.pos.toLocaleString()}`
|
| 8753 |
+ ` Β· ${p.c.cuts === 1 ? 'unique cutter' : p.c.cuts + ' sites'}</title></text>`;
|
|
@@ -9009,12 +9010,51 @@ function runOracle(opts){
|
|
| 9009 |
window.addEventListener('blur', end);
|
| 9010 |
}
|
| 9011 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9012 |
function wireFeatureClicks() {
|
| 9013 |
wireFeatureDrag();
|
| 9014 |
wireMapPan();
|
| 9015 |
if (mapWrap.dataset.featWired === '1') return;
|
| 9016 |
mapWrap.dataset.featWired = '1';
|
| 9017 |
mapWrap.addEventListener('click', (e) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9018 |
const el = e.target.closest && e.target.closest('[data-feat]');
|
| 9019 |
if (!el || !plasmid) return;
|
| 9020 |
// By INDEX when the element carries one. Resolving by name picked
|
|
@@ -9036,11 +9076,18 @@ function runOracle(opts){
|
|
| 9036 |
/* Clicking bare backbone drops a caret at that coordinate. This lived
|
| 9037 |
on the deleted companion map; without it the real map would have
|
| 9038 |
become click-to-select-features only, which is a capability LOSS
|
| 9039 |
-
dressed up as a rewrite. Feature clicks are handled
|
| 9040 |
-
here, so
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9041 |
mapWrap.addEventListener('click', (e) => {
|
| 9042 |
if (!plasmid) return;
|
| 9043 |
if (e.target.closest && e.target.closest('[data-feat]')) return;
|
|
|
|
| 9044 |
const svg = mapWrap.querySelector('svg');
|
| 9045 |
if (!svg || !svg.contains(e.target)) return;
|
| 9046 |
const r = svg.getBoundingClientRect();
|
|
@@ -9160,12 +9207,12 @@ function runOracle(opts){
|
|
| 9160 |
let lastCutX = -1e9;
|
| 9161 |
linCuts.shown.slice().sort((a, b) => a.pos - b.pos).forEach((c) => {
|
| 9162 |
const xx = x(c.pos);
|
| 9163 |
-
svg += `<line x1="${xx.toFixed(1)}" y1="20" x2="${xx.toFixed(1)}" y2="${trackY + 30}" class="pm-cut"/>`;
|
| 9164 |
// One baseline, so two names closer than ~46px would overlap.
|
| 9165 |
// Alternate rows rather than drop the second one.
|
| 9166 |
const row = (xx - lastCutX) < 46 ? 1 : 0;
|
| 9167 |
if (row === 0) lastCutX = xx;
|
| 9168 |
-
svg += `<text x="${xx.toFixed(1)}" y="${row ? 6 : 15}" class="pm-cut-lbl" text-anchor="middle">${esc(c.name)}<title>${esc(c.name)} Β· cuts at ${c.pos.toLocaleString()} Β· ${c.cuts === 1 ? 'unique cutter' : c.cuts + ' sites'}</title></text>`;
|
| 9169 |
});
|
| 9170 |
svg += `</svg>`;
|
| 9171 |
mapWrap.innerHTML = `<div class="plasmid-linear-scroll">${svg}</div>`;
|
|
@@ -10514,6 +10561,19 @@ function runOracle(opts){
|
|
| 10514 |
else showSeqOut('Clipboard not available here.');
|
| 10515 |
}
|
| 10516 |
function showSeqOut(html) { const o = $('plasmidSeqOutput'); if (o) { o.hidden = false; o.innerHTML = html; } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10517 |
|
| 10518 |
function _featColorAt(i, feats) { for (const f of feats) if (i >= f.start && i < f.end) return f.color || '#9AA0AA'; return ''; }
|
| 10519 |
function _inFind(i) { for (const h of findHits) if (i >= h && i < h + findLen) return true; return false; }
|
|
@@ -10586,7 +10646,9 @@ function runOracle(opts){
|
|
| 10586 |
out += '<div class="seqed-line seqed-cut"><span class="seqed-pos"></span>'
|
| 10587 |
+ `<span class="seqed-bases" style="--w:${w}">`;
|
| 10588 |
for (const c of inLine) {
|
| 10589 |
-
out += `<span class="seqcut" style="left:${c.pos - ls}ch"
|
|
|
|
|
|
|
| 10590 |
+ `<i class="seqcut-tick"></i><b>${esc(c.name)}</b></span>`;
|
| 10591 |
}
|
| 10592 |
out += '</span></div>';
|
|
@@ -10816,6 +10878,14 @@ function runOracle(opts){
|
|
| 10816 |
// repaint and a full render is the only way to show the selection.
|
| 10817 |
if (seqSpans) paintSel(from - 1, to);
|
| 10818 |
else renderSequence();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10819 |
updateSelArc();
|
| 10820 |
renderStatus();
|
| 10821 |
announceSelection();
|
|
@@ -10993,7 +11063,17 @@ function runOracle(opts){
|
|
| 10993 |
else { const p = caret == null ? len : caret; caret = p + ins.length; replaceRange(p, p, ins, true); }
|
| 10994 |
sel = null; selAnchor = null; caretVisible(); focusEditor();
|
| 10995 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10996 |
seqWrap.addEventListener('mousedown', (e) => {
|
|
|
|
| 10997 |
if (!isEditable()) return;
|
| 10998 |
const i = idxFromEvent(e); if (i == null) return;
|
| 10999 |
e.preventDefault();
|
|
|
|
| 8734 |
// Tick marks first, under every label.
|
| 8735 |
cutInfo.shown.forEach((c) => {
|
| 8736 |
const [x1, y1] = ptAt(c.pos, len, R - 12, cx, cy), [x2, y2] = ptAt(c.pos, len, R + 20, cx, cy);
|
| 8737 |
+
svg += `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" class="pm-cut" data-cut="${c.pos}" data-enz="${esc(c.name)}"/>`;
|
| 8738 |
});
|
| 8739 |
items.forEach((p) => {
|
| 8740 |
const anchor = p.right ? 'start' : 'end';
|
|
|
|
| 8748 |
+ `dominant-baseline="middle">${esc(p.f.name)}</text>`;
|
| 8749 |
} else {
|
| 8750 |
svg += `<text x="${tx.toFixed(1)}" y="${p.ly.toFixed(1)}" class="pm-cut-lbl" `
|
| 8751 |
+
+ `data-cut="${p.c.pos}" data-enz="${esc(p.c.name)}" `
|
| 8752 |
+ `text-anchor="${anchor}" dominant-baseline="middle">${esc(p.c.name)}`
|
| 8753 |
+ `<title>${esc(p.c.name)} Β· cuts at ${p.c.pos.toLocaleString()}`
|
| 8754 |
+ ` Β· ${p.c.cuts === 1 ? 'unique cutter' : p.c.cuts + ' sites'}</title></text>`;
|
|
|
|
| 9010 |
window.addEventListener('blur', end);
|
| 9011 |
}
|
| 9012 |
|
| 9013 |
+
/* A named cut site is a thing you can ACT on, so it answers a click.
|
| 9014 |
+
|
| 9015 |
+
It was decoration: the ring and the sequence both drew the tick and the
|
| 9016 |
+
enzyme's name, and neither did anything when you pressed it β you read
|
| 9017 |
+
"BsrGI" off the map and then went hunting for the coordinate by hand.
|
| 9018 |
+
|
| 9019 |
+
Clicking one puts the caret on that exact cut and scrolls the sequence
|
| 9020 |
+
to it, then lists EVERY site that enzyme has. That second part is the
|
| 9021 |
+
point: an enzyme cutting five times is a different decision from one
|
| 9022 |
+
cutting once, and the map only ever draws a subset (see cutSitesForMap).
|
| 9023 |
+
The positions come from the analysis, never from the drawing, so the
|
| 9024 |
+
list is complete even when the ring is showing nine of a hundred. */
|
| 9025 |
+
function showCutSite(pos, enz) {
|
| 9026 |
+
if (!plasmid || !restriction) return;
|
| 9027 |
+
const p = parseInt(pos, 10);
|
| 9028 |
+
if (!Number.isInteger(p) || p < 1 || p > plasmid.length) return;
|
| 9029 |
+
setSel(p, p); // the cut itself β 1 bp, a caret on it
|
| 9030 |
+
scrollSelIntoView();
|
| 9031 |
+
const e = (restriction.enzymes || []).find((x) => x.name === enz);
|
| 9032 |
+
const sites = (e && e.sites) || [];
|
| 9033 |
+
if (!sites.length) { showSeqOut(`<b>${esc(String(enz))}</b> cuts at ${p.toLocaleString()}.`); return; }
|
| 9034 |
+
const idx = sites.indexOf(p);
|
| 9035 |
+
const list = sites.map((s) => (s === p
|
| 9036 |
+
? `<b>${s.toLocaleString()}</b>`
|
| 9037 |
+
: `<a href="#" class="seq-cut-jump" data-cut="${s}" data-enz="${esc(String(enz))}">${s.toLocaleString()}</a>`
|
| 9038 |
+
)).join(', ');
|
| 9039 |
+
showSeqOut(`<b>${esc(String(enz))}</b> β ${sites.length} site${sites.length === 1 ? '' : 's'}`
|
| 9040 |
+
+ (idx >= 0 ? ` (this is #${idx + 1})` : '')
|
| 9041 |
+
+ (e.overhang ? ` Β· ${esc(e.overhang)}` : '')
|
| 9042 |
+
+ `<br>cuts at ${list}`);
|
| 9043 |
+
}
|
| 9044 |
+
|
| 9045 |
function wireFeatureClicks() {
|
| 9046 |
wireFeatureDrag();
|
| 9047 |
wireMapPan();
|
| 9048 |
if (mapWrap.dataset.featWired === '1') return;
|
| 9049 |
mapWrap.dataset.featWired = '1';
|
| 9050 |
mapWrap.addEventListener('click', (e) => {
|
| 9051 |
+
// A cut mark sits ON a feature arc, so it has to be checked first
|
| 9052 |
+
// or the feature underneath swallows every click.
|
| 9053 |
+
const cut = e.target.closest && e.target.closest('[data-cut]');
|
| 9054 |
+
if (cut) {
|
| 9055 |
+
showCutSite(cut.getAttribute('data-cut'), cut.getAttribute('data-enz'));
|
| 9056 |
+
return;
|
| 9057 |
+
}
|
| 9058 |
const el = e.target.closest && e.target.closest('[data-feat]');
|
| 9059 |
if (!el || !plasmid) return;
|
| 9060 |
// By INDEX when the element carries one. Resolving by name picked
|
|
|
|
| 9076 |
/* Clicking bare backbone drops a caret at that coordinate. This lived
|
| 9077 |
on the deleted companion map; without it the real map would have
|
| 9078 |
become click-to-select-features only, which is a capability LOSS
|
| 9079 |
+
dressed up as a rewrite. Feature AND cut-site clicks are handled
|
| 9080 |
+
above and stop here, so they never fight over one click.
|
| 9081 |
+
Both exclusions are load-bearing: this is a SEPARATE listener, so
|
| 9082 |
+
returning early in the handler above does not stop it. A cut site
|
| 9083 |
+
sits on a leader line pointing away from the ring, so without the
|
| 9084 |
+
`[data-cut]` guard a click on the enzyme's name resolved to the
|
| 9085 |
+
angle of the LABEL rather than the cut, and the caret landed
|
| 9086 |
+
somewhere else entirely. */
|
| 9087 |
mapWrap.addEventListener('click', (e) => {
|
| 9088 |
if (!plasmid) return;
|
| 9089 |
if (e.target.closest && e.target.closest('[data-feat]')) return;
|
| 9090 |
+
if (e.target.closest && e.target.closest('[data-cut]')) return;
|
| 9091 |
const svg = mapWrap.querySelector('svg');
|
| 9092 |
if (!svg || !svg.contains(e.target)) return;
|
| 9093 |
const r = svg.getBoundingClientRect();
|
|
|
|
| 9207 |
let lastCutX = -1e9;
|
| 9208 |
linCuts.shown.slice().sort((a, b) => a.pos - b.pos).forEach((c) => {
|
| 9209 |
const xx = x(c.pos);
|
| 9210 |
+
svg += `<line x1="${xx.toFixed(1)}" y1="20" x2="${xx.toFixed(1)}" y2="${trackY + 30}" class="pm-cut" data-cut="${c.pos}" data-enz="${esc(c.name)}"/>`;
|
| 9211 |
// One baseline, so two names closer than ~46px would overlap.
|
| 9212 |
// Alternate rows rather than drop the second one.
|
| 9213 |
const row = (xx - lastCutX) < 46 ? 1 : 0;
|
| 9214 |
if (row === 0) lastCutX = xx;
|
| 9215 |
+
svg += `<text x="${xx.toFixed(1)}" y="${row ? 6 : 15}" class="pm-cut-lbl" data-cut="${c.pos}" data-enz="${esc(c.name)}" text-anchor="middle">${esc(c.name)}<title>${esc(c.name)} Β· cuts at ${c.pos.toLocaleString()} Β· ${c.cuts === 1 ? 'unique cutter' : c.cuts + ' sites'}</title></text>`;
|
| 9216 |
});
|
| 9217 |
svg += `</svg>`;
|
| 9218 |
mapWrap.innerHTML = `<div class="plasmid-linear-scroll">${svg}</div>`;
|
|
|
|
| 10561 |
else showSeqOut('Clipboard not available here.');
|
| 10562 |
}
|
| 10563 |
function showSeqOut(html) { const o = $('plasmidSeqOutput'); if (o) { o.hidden = false; o.innerHTML = html; } }
|
| 10564 |
+
/* Every position showCutSite prints is itself a jump β bound once, here,
|
| 10565 |
+
rather than re-bound on each render. */
|
| 10566 |
+
(function wireCutJumps() {
|
| 10567 |
+
const o = $('plasmidSeqOutput');
|
| 10568 |
+
if (!o || o.dataset.cutJumpWired === '1') return;
|
| 10569 |
+
o.dataset.cutJumpWired = '1';
|
| 10570 |
+
o.addEventListener('click', (e) => {
|
| 10571 |
+
const a = e.target.closest && e.target.closest('.seq-cut-jump');
|
| 10572 |
+
if (!a) return;
|
| 10573 |
+
e.preventDefault();
|
| 10574 |
+
showCutSite(a.getAttribute('data-cut'), a.getAttribute('data-enz'));
|
| 10575 |
+
});
|
| 10576 |
+
}());
|
| 10577 |
|
| 10578 |
function _featColorAt(i, feats) { for (const f of feats) if (i >= f.start && i < f.end) return f.color || '#9AA0AA'; return ''; }
|
| 10579 |
function _inFind(i) { for (const h of findHits) if (i >= h && i < h + findLen) return true; return false; }
|
|
|
|
| 10646 |
out += '<div class="seqed-line seqed-cut"><span class="seqed-pos"></span>'
|
| 10647 |
+ `<span class="seqed-bases" style="--w:${w}">`;
|
| 10648 |
for (const c of inLine) {
|
| 10649 |
+
out += `<span class="seqcut" style="left:${c.pos - ls}ch" `
|
| 10650 |
+
+ `data-cut="${c.pos}" data-enz="${esc(c.name)}" `
|
| 10651 |
+
+ `title="${esc(c.name)} cuts at ${c.pos.toLocaleString()} β click for every site">`
|
| 10652 |
+ `<i class="seqcut-tick"></i><b>${esc(c.name)}</b></span>`;
|
| 10653 |
}
|
| 10654 |
out += '</span></div>';
|
|
|
|
| 10878 |
// repaint and a full render is the only way to show the selection.
|
| 10879 |
if (seqSpans) paintSel(from - 1, to);
|
| 10880 |
else renderSequence();
|
| 10881 |
+
// The From/To/length readout is part of the selection, not part of the
|
| 10882 |
+
// render. It used to come along for free because setSel called
|
| 10883 |
+
// renderSequence, which syncs it on the way past β so routing the fast
|
| 10884 |
+
// path through paintSel left the boxes showing whatever was there
|
| 10885 |
+
// before, on EVERY map click, feature or cut site. Cheap and
|
| 10886 |
+
// idempotent, so it runs on both branches rather than only the fast
|
| 10887 |
+
// one.
|
| 10888 |
+
syncSelInputs();
|
| 10889 |
updateSelArc();
|
| 10890 |
renderStatus();
|
| 10891 |
announceSelection();
|
|
|
|
| 11063 |
else { const p = caret == null ? len : caret; caret = p + ins.length; replaceRange(p, p, ins, true); }
|
| 11064 |
sel = null; selAnchor = null; caretVisible(); focusEditor();
|
| 11065 |
}
|
| 11066 |
+
/* A cut mark is a control, not a base. Without this the editor's
|
| 11067 |
+
drag-select starts on mousedown, calls preventDefault, and the click on
|
| 11068 |
+
the enzyme's name never arrives. */
|
| 11069 |
+
seqWrap.addEventListener('click', (e) => {
|
| 11070 |
+
const c = e.target.closest && e.target.closest('[data-cut]');
|
| 11071 |
+
if (!c) return;
|
| 11072 |
+
e.preventDefault();
|
| 11073 |
+
showCutSite(c.getAttribute('data-cut'), c.getAttribute('data-enz'));
|
| 11074 |
+
});
|
| 11075 |
seqWrap.addEventListener('mousedown', (e) => {
|
| 11076 |
+
if (e.target.closest && e.target.closest('[data-cut]')) return;
|
| 11077 |
if (!isEditable()) return;
|
| 11078 |
const i = idxFromEvent(e); if (i == null) return;
|
| 11079 |
e.preventDefault();
|
dee/static/index.html
CHANGED
|
@@ -112,7 +112,7 @@
|
|
| 112 |
<!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
|
| 113 |
app.js change. Bump these numbers whenever you ship a frontend update β
|
| 114 |
without them, users keep getting the stale file for up to a week. -->
|
| 115 |
-
<link rel="stylesheet" href="/static/app.css?v=20260824-
|
| 116 |
<!-- The work catalog + the draggable rail. Kept out of app.css so two new
|
| 117 |
self-contained surfaces stay reviewable; every colour is an app.css
|
| 118 |
token, so both themes work with nothing added. -->
|
|
@@ -3132,9 +3132,9 @@
|
|
| 3132 |
<!-- Cloning reference data must load before app.js so the Designer
|
| 3133 |
can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
|
| 3134 |
<script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
|
| 3135 |
-
<script src="/static/context.js?v=20260824-
|
| 3136 |
-
<script src="/static/lineage.js?v=20260824-
|
| 3137 |
-
<script src="/static/app.js?v=20260824-
|
| 3138 |
<!-- The decision trace, BEFORE cockpit.js: applyEvent calls TDTrace.push
|
| 3139 |
on the very first event, and both are `defer`, so document order is
|
| 3140 |
load order. Loading it after would drop the opening events of a
|
|
@@ -3143,7 +3143,7 @@
|
|
| 3143 |
<!-- THE COCKPIT β the persistent orchestrator rail. Loads after app.js so
|
| 3144 |
TDBench/TDStructure exist when a tool result asks the workspace to
|
| 3145 |
render something. This is the only conversation surface in the app. -->
|
| 3146 |
-
<script src="/static/cockpit.js?v=20260824-
|
| 3147 |
<!-- structcard before catalog: the catalog calls TDStructCard.observe as
|
| 3148 |
soon as it paints. Both are defer, so document order is load order. -->
|
| 3149 |
<script src="/static/structcard.js?v=20260811-buildC" defer></script>
|
|
|
|
| 112 |
<!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
|
| 113 |
app.js change. Bump these numbers whenever you ship a frontend update β
|
| 114 |
without them, users keep getting the stale file for up to a week. -->
|
| 115 |
+
<link rel="stylesheet" href="/static/app.css?v=20260824-cutclick3" />
|
| 116 |
<!-- The work catalog + the draggable rail. Kept out of app.css so two new
|
| 117 |
self-contained surfaces stay reviewable; every colour is an app.css
|
| 118 |
token, so both themes work with nothing added. -->
|
|
|
|
| 3132 |
<!-- Cloning reference data must load before app.js so the Designer
|
| 3133 |
can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
|
| 3134 |
<script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
|
| 3135 |
+
<script src="/static/context.js?v=20260824-cutclick3" defer></script>
|
| 3136 |
+
<script src="/static/lineage.js?v=20260824-cutclick3" defer></script>
|
| 3137 |
+
<script src="/static/app.js?v=20260824-cutclick3" defer></script>
|
| 3138 |
<!-- The decision trace, BEFORE cockpit.js: applyEvent calls TDTrace.push
|
| 3139 |
on the very first event, and both are `defer`, so document order is
|
| 3140 |
load order. Loading it after would drop the opening events of a
|
|
|
|
| 3143 |
<!-- THE COCKPIT β the persistent orchestrator rail. Loads after app.js so
|
| 3144 |
TDBench/TDStructure exist when a tool result asks the workspace to
|
| 3145 |
render something. This is the only conversation surface in the app. -->
|
| 3146 |
+
<script src="/static/cockpit.js?v=20260824-cutclick3" defer></script>
|
| 3147 |
<!-- structcard before catalog: the catalog calls TDStructCard.observe as
|
| 3148 |
soon as it paints. Both are defer, so document order is load order. -->
|
| 3149 |
<script src="/static/structcard.js?v=20260811-buildC" defer></script>
|
tests/test_plasmid_editor_ui.py
CHANGED
|
@@ -546,3 +546,64 @@ def test_a_cut_row_is_tall_enough_to_paint_its_own_enzyme_name():
|
|
| 546 |
".seqed-cut .seqed-bases must be vertical-align: bottom, or the label "
|
| 547 |
"floats up by however much descender space the font happens to have"
|
| 548 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
".seqed-cut .seqed-bases must be vertical-align: bottom, or the label "
|
| 547 |
"floats up by however much descender space the font happens to have"
|
| 548 |
)
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
# --- clicking a named cut site -------------------------------------------
|
| 552 |
+
def test_every_drawn_cut_site_carries_the_data_a_click_needs():
|
| 553 |
+
"""A named cut site answers a click (showCutSite), so every place one is
|
| 554 |
+
DRAWN has to tag it with the position and the enzyme β the circular tick
|
| 555 |
+
and label, the linear tick and label, and the sequence pane. Miss one and
|
| 556 |
+
that surface silently goes back to being decoration.
|
| 557 |
+
|
| 558 |
+
`_code` matters here: the comments around this feature say "data-cut"
|
| 559 |
+
repeatedly, and a guard that greps its own explanation passes for free.
|
| 560 |
+
"""
|
| 561 |
+
js = _code(_read(_APP_JS))
|
| 562 |
+
emitters = re.findall(r'data-cut="\$\{[^"]+\}"', js)
|
| 563 |
+
assert len(emitters) >= 5, (
|
| 564 |
+
f"only {len(emitters)} render path(s) tag a cut site; expected the "
|
| 565 |
+
f"circular tick + label, the linear tick + label, and the sequence pane"
|
| 566 |
+
)
|
| 567 |
+
# position AND enzyme β the site list is per-enzyme, so a bare position
|
| 568 |
+
# cannot answer "what else does this cut?"
|
| 569 |
+
assert js.count("data-enz=") >= 5, "cut marks must name their enzyme too"
|
| 570 |
+
assert "function showCutSite" in js, "the click has no handler"
|
| 571 |
+
|
| 572 |
+
|
| 573 |
+
def test_the_backbone_caret_does_not_swallow_a_cut_site_click():
|
| 574 |
+
"""The map has TWO click listeners: one selects features and cut sites, a
|
| 575 |
+
second drops a caret wherever you click bare backbone. They are separate
|
| 576 |
+
listeners, so returning early in the first does not stop the second.
|
| 577 |
+
|
| 578 |
+
Without a `[data-cut]` guard in the second, clicking an enzyme's name
|
| 579 |
+
resolved to the angle of the LABEL β which sits out on a leader line β and
|
| 580 |
+
the caret landed somewhere else entirely, overwriting the correct
|
| 581 |
+
selection a few microseconds after it was made. Measured before the fix:
|
| 582 |
+
clicking BsaI at 794 selected 1153.
|
| 583 |
+
"""
|
| 584 |
+
js = _code(_read(_APP_JS))
|
| 585 |
+
backbone = re.search(
|
| 586 |
+
r"mapWrap\.addEventListener\('click',\s*\(e\)\s*=>\s*\{\s*"
|
| 587 |
+
r"if \(!plasmid\) return;(.*?)\n\s*const svg", js, re.S)
|
| 588 |
+
assert backbone, "the bare-backbone caret handler moved or was renamed"
|
| 589 |
+
guards = backbone.group(1)
|
| 590 |
+
assert "data-feat" in guards, "backbone handler lost its feature guard"
|
| 591 |
+
assert "data-cut" in guards, (
|
| 592 |
+
"backbone handler must skip cut marks, or it overwrites the selection "
|
| 593 |
+
"showCutSite just made"
|
| 594 |
+
)
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
def test_setting_a_selection_updates_the_from_to_readout():
|
| 598 |
+
"""`setSel` paints the selection directly (paintSel) instead of rebuilding
|
| 599 |
+
the pane, which is what made map clicks fast. But the From/To/length boxes
|
| 600 |
+
were synced by renderSequence on the way past, so the fast path left them
|
| 601 |
+
showing the PREVIOUS selection β on every map click, feature or cut site.
|
| 602 |
+
"""
|
| 603 |
+
js = _code(_read(_APP_JS))
|
| 604 |
+
body = re.search(r"function setSel\(from, to\)\s*\{(.*?)\n \}", js, re.S)
|
| 605 |
+
assert body, "setSel moved or was renamed"
|
| 606 |
+
assert "syncSelInputs()" in body.group(1), (
|
| 607 |
+
"setSel must sync the From/To readout β paintSel does not, and "
|
| 608 |
+
"renderSequence is no longer on the common path"
|
| 609 |
+
)
|