document.addEventListener('DOMContentLoaded', () => { const tabButtons = document.querySelectorAll('[data-editor-tab]'); const panels = document.querySelectorAll('[data-editor-panel]'); const panelShell = document.querySelector('.editor-tab-panels'); const contextTabsWrap = document.querySelector('.ctx-tabs'); let activeEditorTab = 'assets'; let editorSyncDepth = 0; let isRevertingContext = false; function setPanelOverlay(on, label = 'Syncing') { if (!panelShell) return; let overlay = panelShell.querySelector('.editor-panel-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.className = 'editor-panel-overlay'; overlay.innerHTML = '
Syncing
'; panelShell.appendChild(overlay); } const clean = String(label || 'Syncing').replace(/\.*\s*$/, ''); const overlayLabel = overlay.querySelector('.label'); if (overlayLabel) { overlayLabel.textContent = clean; overlayLabel.setAttribute('data-ellipsis', ''); } overlay.classList.toggle('show', !!on); panelShell.classList.toggle('is-sync-locked', !!on); document.querySelector('.editor-tabs')?.classList.toggle('is-sync-locked', !!on); } async function withEditorSync(label, run) { editorSyncDepth += 1; setPanelOverlay(true, label || 'Syncing'); try { return await run(); } finally { editorSyncDepth = Math.max(0, editorSyncDepth - 1); if (!editorSyncDepth) setPanelOverlay(false); } } function selectEditorTab(target) { if (!target) return; if (editorSyncDepth > 0 && target !== activeEditorTab) return; activeEditorTab = target; tabButtons.forEach((b) => { const isActive = b.getAttribute('data-editor-tab') === target; b.classList.toggle('is-active', isActive); b.setAttribute('aria-selected', isActive ? 'true' : 'false'); }); panels.forEach((panel) => { const match = panel.getAttribute('data-editor-panel') === target; panel.classList.toggle('is-active', match); }); updateHeaderActionsForTab?.(); if (window.location.hash !== `#${target}`) { history.replaceState(null, '', `#${target}`); } if (target === 'assets') { if (!isMemberSignedIn) { selectEditorTab('sequence'); redirectToSignUp(); return; } refreshAssetsForCurrentContext?.(); } } if (tabButtons.length && panels.length) { tabButtons.forEach((btn) => { btn.addEventListener('click', () => selectEditorTab(btn.getAttribute('data-editor-tab'))); }); } window.addEventListener('hashchange', () => { const h = String(window.location.hash || '').replace(/^#/, '').toLowerCase(); if (['assets', 'sequence', 'structure', 'ligand'].includes(h)) selectEditorTab(h); }); const editorRoot = document.getElementById('vici-editor'); if (editorRoot && editorRoot.dataset.seqEditorInit === '1') return; if (editorRoot) editorRoot.dataset.seqEditorInit = '1'; const nextFrame = () => new Promise(requestAnimationFrame); const EDITOR_SAVE_URL = 'https://ayambabu23--workflow-execute-workflow.modal.run/'; const EDITOR_STATUS_URL = 'https://ayambabu23--workflow-check-status.modal.run/'; const DELETE_URL = window.MODAL_DELETE_URL || 'https://ayambabu23--workflow-delete-workflow.modal.run/'; const DOWNLOAD_URL = window.MODAL_DOWNLOAD_URL || 'https://ayambabu23--workflow-download.modal.run'; const EMPTY_RIBBON_URL = 'https://cdn.prod.website-files.com/685009238c2e9a16d785299f/68cd6d37b9d5aee9c675aac9_vici_empty_state_ribbon_animated.svg'; const SEQUENCE_EXTENSIONS = new Set(['fasta', 'fa', 'fna', 'faa', 'txt', 'seq', 'json']); const STRUCTURE_EXTENSIONS = new Set(['pdb', 'ent', 'cif', 'mmcif']); const LIGAND_EXTENSIONS = new Set(['sdf', 'mol', 'mol2', 'pdbqt']); let currentAssetPath = null; let activeScopeId = null; let hasUnsavedChanges = false; let autoSaveTimer = null; let autoSaveInFlight = null; let suppressAssetsOverlayOnce = false; let isApplyingPersistedState = false; let warnedLegacyFasta = false; let isMemberSignedIn = false; const SIGN_UP_PATH = '/sign-up'; function redirectToSignUp() { window.location.href = SIGN_UP_PATH; } async function detectMemberSignIn() { try { const ms = window.$memberstackDom; if (!ms?.getCurrentMember) return false; const { data } = await ms.getCurrentMember(); return !!data?.id; } catch (_) { return false; } } function getFileExtension(name) { const base = String(name || '').trim(); const idx = base.lastIndexOf('.'); return idx >= 0 ? base.slice(idx + 1).toLowerCase() : ''; } function currentContextKey(memberId) { return String(window.ViciContext?.get?.() || sessionStorage.getItem('vici:ctx') || `user:${memberId}`); } function resolveEditorUserId(scopePayload, fallback = '') { const explicit = String(scopePayload?.user_id || '').trim(); const memberId = explicit || String(fallback || '').trim(); const teamId = String(scopePayload?.team_id || scopePayload?.workspace_id || '').trim(); if (!memberId) return ''; if (!teamId) return memberId; return memberId.includes('+') ? memberId : `${memberId}+${teamId}`; } function resolveEditorTeamId(scopePayload) { return String(scopePayload?.team_id || scopePayload?.workspace_id || '').trim(); } function stripTrailingExtension(name) { const base = String(name || '').trim(); return base.replace(/\.[A-Za-z0-9]+$/, ''); } function toCleanAssetLabel(name) { const base = stripTrailingExtension(String(name || '').split('/').pop() || ''); const cleaned = base .replace(/[_\-.]+/g, ' ') .replace(/\b[0-9a-f]{14,}\b/gi, ' ') .replace(/\bfasta\d*\b/gi, ' ') .replace(/\s+/g, ' ') .replace(/\b([A-Za-z])\s+(?=[A-Za-z]\b)/g, '$1') .replace(/\b([0-9A-Za-z])\s+(?=[0-9A-Za-z]\b)/g, '$1') .trim(); return cleaned || 'Untitled'; } function assetKindFromPath(path) { const ext = getFileExtension(path); if (STRUCTURE_EXTENSIONS.has(ext)) return 'structure'; if (LIGAND_EXTENSIONS.has(ext)) return 'ligand'; return 'sequence'; } function assetTypeLabel(path) { const ext = getFileExtension(path).toUpperCase(); const kind = assetKindFromPath(path); if (ext) return ext; return kind === 'sequence' ? 'DNA' : (kind === 'structure' ? 'PDB' : 'SDF'); } async function getCurrentMemberData() { const ms = window.$memberstackDom; if (!ms?.getCurrentMember) throw new Error('Member session unavailable'); const { data } = await ms.getCurrentMember(); if (!data?.id) throw new Error('Missing member id'); return data; } async function getEditorAuthContext() { const member = await getCurrentMemberData(); const jwt = await window.ViciAuth?.getJwt?.(); if (!jwt) throw new Error('Missing session token. Please sign in again.'); const payload = window.ViciContext?.payloadFor?.(member.id) || { user_id: member.id }; return { member, jwt, payload, contextKey: currentContextKey(member.id) }; } async function listEditorAssets(scopePayload, jwt) { const resp = await fetch(EDITOR_STATUS_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}`, }, body: JSON.stringify({ ...scopePayload, editor: true }) }); if (!resp.ok) throw new Error(`List failed (${resp.status})`); const json = await resp.json(); return Array.isArray(json?.paths) ? json.paths : []; } async function fetchEditorAsset(scopePayload, path, jwt) { const resp = await fetch(EDITOR_STATUS_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}`, }, body: JSON.stringify({ ...scopePayload, editor: true, path }) }); if (!resp.ok) throw new Error(`Fetch failed (${resp.status})`); return await resp.json(); } async function saveEditorAsset(scopePayload, payload, jwt) { const resp = await fetch(EDITOR_SAVE_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}`, }, body: JSON.stringify({ ...scopePayload, ...payload }) }); if (!resp.ok) throw new Error(`Save failed (${resp.status})`); return await resp.json(); } async function deleteEditorAsset(scopePayload, path, jwt) { const fullName = String(path || '').split('/').pop() || ''; const ext = getFileExtension(fullName); const fileName = stripTrailingExtension(fullName) || fullName || 'asset'; const resp = await fetch(DELETE_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}`, }, body: JSON.stringify({ user_id: resolveEditorUserId(scopePayload), team_id: resolveEditorTeamId(scopePayload), editor: true, file_name: fileName, file_extension: (ext || 'fasta').toLowerCase(), }) }); if (!resp.ok) throw new Error(`Delete failed (${resp.status})`); return await resp.json(); } function buildDuplicatedAssetName(path, existingPaths = []) { const fullName = String(path || '').split('/').pop() || 'asset.fasta'; const ext = getFileExtension(fullName) || 'fasta'; const base = toCleanAssetLabel(fullName) || 'asset'; const existing = new Set((existingPaths || []).map((p) => String(p || '').split('/').pop() || '')); let candidate = `${base} copy.${ext}`; let idx = 2; while (existing.has(candidate)) { candidate = `${base} copy ${idx}.${ext}`; idx += 1; } return candidate; } async function duplicateEditorAsset(scopePayload, path, jwt, existingPaths = [], preferredName = '') { const original = await fetchEditorAsset(scopePayload, path, jwt); const content = original?.editor_content || original; if (!content || typeof content.file_content !== 'string') throw new Error('Failed to duplicate asset'); const requestedName = String(preferredName || '').trim(); const normalizedName = requestedName ? (requestedName.includes('.') ? requestedName : `${requestedName}.${getFileExtension(path) || 'fasta'}`) : ''; const newName = normalizedName || buildDuplicatedAssetName(path, existingPaths); const ext = getFileExtension(newName) || getFileExtension(path) || 'fasta'; const baseName = stripTrailingExtension(newName) || 'asset'; await saveEditorAsset(scopePayload, { editor_content: { file_name: newName, file_extension: ext.toLowerCase(), file_content: content.file_content, ui_state: content.ui_state || null, } }, jwt); return { path: newName, label: baseName }; } async function downloadEditorAsset(scopePayload, path, jwt) { const fullName = String(path || '').split('/').pop() || ''; const ext = getFileExtension(fullName); const fileName = stripTrailingExtension(fullName) || fullName || 'asset'; const resp = await fetch(DOWNLOAD_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}`, }, body: JSON.stringify({ user_id: resolveEditorUserId(scopePayload), team_id: resolveEditorTeamId(scopePayload), editor: true, file_name: fileName, file_extension: (ext || 'fasta').toLowerCase(), }) }); if (!resp.ok) throw new Error(`Download failed (${resp.status})`); const contentType = String(resp.headers.get('content-type') || '').toLowerCase(); if (contentType.includes('application/json')) { const json = await resp.json(); if (typeof json?.download_url === 'string' && json.download_url) { return { name: fullName || `${fileName}.${ext || 'fasta'}`, url: json.download_url, source: 'url' }; } if (typeof json?.file_content === 'string') { return { name: fullName || `${fileName}.${ext || 'fasta'}`, blob: new Blob([json.file_content], { type: 'text/plain;charset=utf-8' }), source: 'content' }; } throw new Error('Download response missing file payload'); } const blob = await resp.blob(); return { name: fullName || `${fileName}.${ext || 'fasta'}`, blob, source: 'blob' }; } function triggerEditorAssetDownload(downloadData) { if (!downloadData) return; const link = document.createElement('a'); link.rel = 'noopener'; link.download = downloadData.name || 'asset'; if (downloadData.url) { link.href = downloadData.url; } else if (downloadData.blob) { link.href = URL.createObjectURL(downloadData.blob); } else { throw new Error('Invalid download payload'); } document.body.appendChild(link); link.click(); link.remove(); if (downloadData.blob) { setTimeout(() => URL.revokeObjectURL(link.href), 1000); } } let seqRaw = ''; let translationFrameOffset = 0; let translationAutoCycle = false; let translationRanges = null; let lastChainMeta = null; const proteinCodonOverrides = new Map(); function entryKey(entry, idx) { const h = String(entry?.header || '').trim(); return h ? h : `__seq_${idx}`; } function getDerivedNucForProtein(entry, idx, protSeq, desiredType) { const key = entryKey(entry, idx); const over = proteinCodonOverrides.get(key); if (over && typeof over.seq === 'string' && over.seq.length === (String(protSeq || '').length * 3)) { // Normalize stored seq to the requested display type (dna/rna) const stored = (over.nucType === 'rna') ? toRNA(over.seq) : toDNA(over.seq); return (desiredType === 'rna') ? toRNA(stored) : toDNA(stored); } return backTranslateProtein(protSeq, desiredType); } const seqViewer = document.getElementById('seq-viewer'); const lenEl = document.getElementById('seq-length'); const posEl = document.getElementById('seq-pos'); let isEditingRange = false; const rangeBox = document.querySelector('.seq-meta-box--range'); rangeBox?.addEventListener('click', (e) => { if (e.target !== posEl) posEl.focus(); }); const lenBox = document.querySelector('.seq-meta-box--len'); lenBox?.addEventListener('mousedown', (e) => { if (e.shiftKey || e.detail >= 2) return; const txt = getMetaLengthPlain(); if (!txt) return; e.preventDefault(); e.stopPropagation(); copyToClipboard(txt, 'Copied length'); }); posEl.setAttribute('contenteditable', 'true'); posEl.setAttribute('spellcheck', 'false'); posEl.setAttribute('role', 'textbox'); posEl.setAttribute('aria-label', 'Range (e.g. 1-100, 150-200)'); const typePill = document.getElementById('seq-type-pill'); const lockBtn = document.getElementById('seq-lock-toggle'); const groupToggleBtn = document.getElementById('seq-group-toggle'); const nameInput = document.getElementById('seq-name-input'); const undoBtn = document.getElementById('seq-undo-btn'); const redoBtn = document.getElementById('seq-redo-btn'); const copyBtn = document.getElementById('seq-copy-btn'); const clearBtn = document.getElementById('seq-clear-btn'); const zoomInBtn = document.getElementById('seq-zoom-in-btn'); const zoomOutBtn = document.getElementById('seq-zoom-out-btn'); const reverseBtn = document.getElementById('seq-reverse-btn'); const translateBtn = document.getElementById('seq-translate'); const proteinColorSelect = document.getElementById('seq-protein-color-mode'); const ntColorSelect = document.getElementById('seq-nt-color-mode'); const newFileBtn = document.getElementById('editor-new-file'); const headerFileInput = document.getElementById('editor-file-input'); const editFileBtn = document.getElementById('editor-edit-file'); const EDIT_PENCIL_SVG = ` `; const UPLOAD_SVG = ` `; function viewerHasContent() { if (!seqViewer) return false; const t = (seqViewer.textContent || '').trim(); if (t.length) return true; return !!seqViewer.querySelector?.('.seq-cell, .seq-triplet-cell'); } function updateEditButtonState() { if (!editFileBtn) return; const has = (String(seqRaw || '').trim().length > 0) || viewerHasContent(); const emptyRibbon = document.getElementById('seq-empty-ribbon'); if (emptyRibbon) emptyRibbon.style.display = has ? 'none' : ''; editFileBtn.dataset.mode = has ? 'close' : 'upload'; contextTabsWrap?.classList.toggle('is-context-locked', has); updateHeaderActionsForTab?.(); } updateEditButtonState(); if (seqViewer) { const mo = new MutationObserver(() => updateEditButtonState()); mo.observe(seqViewer, { childList: true, subtree: true, characterData: true }); } seqViewer?.addEventListener('click', (e) => { if (viewerHasContent()) return; openSeqModal(''); }); const newTypeBackdrop = document.getElementById('editor-new-type-backdrop'); const btnRulers = document.getElementById('seq-toggle-rulers-btn'); const btnProtein = document.getElementById('seq-toggle-protein-btn'); const btnDNA = document.getElementById('seq-toggle-dna-btn'); const btnRNA = document.getElementById('seq-toggle-rna-btn'); const btnRevComp = document.getElementById('seq-toggle-revcomp-btn'); const gcMeter = document.getElementById('seq-gc-meter'); const gcLabel = document.getElementById('seq-gc-label'); const atLabel = document.getElementById('seq-at-label'); const gcBar = document.getElementById('seq-gc-bar'); const copyMenu = document.getElementById('seq-copy-menu'); const copyProteinWholeBtn = document.getElementById('seq-copy-protein'); const copyDNAWholeBtn = document.getElementById('seq-copy-dna'); const copyRNAWholeBtn = document.getElementById('seq-copy-rna'); const copyRevWholeBtn = document.getElementById('seq-copy-revcomp'); const highlightNameInput = document.getElementById('seq-highlight-name'); const highlightSwatches = document.getElementById('seq-highlight-swatches'); const highlightTrashBtn = document.getElementById('seq-clear-highlight'); const subtoolbar = document.getElementById('seq-subtoolbar'); const subtoolbarRight = document.getElementById('seq-subtoolbar-right'); let subtoolbarHideTimer = null; const seqModalBackdrop = document.getElementById('seq-modal-backdrop'); const seqModalClose = document.getElementById('seq-modal-close'); const seqModalApply = document.getElementById('seq-modal-apply'); const seqModalClear = document.getElementById('seq-modal-clear'); const seqModalInput = document.getElementById('seq-modal-input'); const seqModalFileInput = document.getElementById('seq-modal-file-input'); const seqModalDropzone = document.getElementById('seq-modal-dropzone'); const mutModalBackdrop = document.getElementById('mut-modal-backdrop'); const mutModalClose = document.getElementById('mut-modal-close'); const mutModalCancel = document.getElementById('mut-modal-cancel'); const mutModalSave = document.getElementById('mut-modal-save'); const mutMiniViewer = document.getElementById('mut-mini-viewer'); const mutPicked = document.getElementById('mut-picked'); const mutOptions = document.getElementById('mut-options'); const mutUndoBtn = document.getElementById('mut-undo-btn'); const mutRedoBtn = document.getElementById('mut-redo-btn'); const mutCopyBtn = document.getElementById('mut-copy-btn'); const mutInsertLeft = document.getElementById('mut-insert-left'); const mutInsertRight = document.getElementById('mut-insert-right'); const mutDeleteBtn = document.getElementById('mut-delete'); const mutActions = document.getElementById('mut-actions'); const seqBlocksWrap = document.getElementById('seq-blocks'); const seqAddBlockBtn = document.getElementById('seq-add-block'); const AA3 = { A:'ALA', R:'ARG', N:'ASN', D:'ASP', C:'CYS', Q:'GLN', E:'GLU', G:'GLY', H:'HIS', I:'ILE', L:'LEU', K:'LYS', M:'MET', F:'PHE', P:'PRO', S:'SER', T:'THR', W:'TRP', Y:'TYR', V:'VAL', U:'SEC', O:'PYL', X:'XAA', '*':'STOP' }; function aaToThree(aa){ const k = String(aa || '').toUpperCase(); return AA3[k] || 'XAA'; } function refreshSeqBlockMoveButtons() { if (!seqBlocksWrap) return; const blocks = Array.from(seqBlocksWrap.querySelectorAll('.seq-block')); blocks.forEach((b, i) => { const up = b.querySelector('.seq-block-move-up'); const dn = b.querySelector('.seq-block-move-down'); if (up) up.disabled = (i === 0); if (dn) dn.disabled = (i === blocks.length - 1); }); } function moveSeqBlock(blockEl, dir) { if (!seqBlocksWrap || !blockEl) return; const prev = blockEl.previousElementSibling; const next = blockEl.nextElementSibling; if (dir < 0 && prev?.classList.contains('seq-block')) { seqBlocksWrap.insertBefore(blockEl, prev); } else if (dir > 0 && next?.classList.contains('seq-block')) { seqBlocksWrap.insertBefore(next, blockEl); } else { return; } syncHiddenModalText(); refreshSeqBlockMoveButtons(); } function animateSeqBlockIn(el) { if (!el) return; el.style.overflow = 'hidden'; el.style.maxHeight = '0px'; el.style.opacity = '0'; el.style.transform = 'translateY(-8px)'; el.style.transition = 'max-height .28s ease, opacity .28s ease, transform .28s ease'; const h = el.scrollHeight || 0; requestAnimationFrame(() => { el.style.maxHeight = h + 'px'; el.style.opacity = '1'; el.style.transform = 'translateY(0)'; }); const onEnd = (e) => { if (e.propertyName !== 'max-height') return; el.removeEventListener('transitionend', onEnd); el.style.transition = ''; el.style.maxHeight = 'none'; el.style.overflow = 'visible'; el.style.opacity = ''; el.style.transform = ''; }; el.addEventListener('transitionend', onEnd); } function animateSeqBlockOut(el, done) { if (!el) return done?.(); el.style.overflow = 'hidden'; const h = el.scrollHeight || 0; el.style.maxHeight = h + 'px'; el.style.opacity = '1'; el.style.transform = 'translateY(0)'; el.style.transition = 'max-height .22s ease, opacity .22s ease, transform .22s ease'; requestAnimationFrame(() => { el.style.maxHeight = '0px'; el.style.opacity = '0'; el.style.transform = 'translateY(-8px)'; }); let called = false; const finish = () => { if (called) return; called = true; done?.(); }; el.addEventListener('transitionend', (e) => { if (e.propertyName === 'max-height') finish(); }, { once:true }); setTimeout(finish, 320); } function buildSeqBlock({ name = '', seq = '', type = 'protein' } = {}) { const div = document.createElement('div'); div.className = 'seq-block'; div.innerHTML = `
`; const ta = div.querySelector('.seq-block-seq'); if (ta) ta.value = String(seq || ''); div.querySelector('.seq-block-move-up')?.addEventListener('click', () => moveSeqBlock(div, -1)); div.querySelector('.seq-block-move-down')?.addEventListener('click', () => moveSeqBlock(div, +1)); const typeSel = div.querySelector('.seq-block-type'); typeSel.value = type || 'protein'; div.querySelector('.seq-block-remove').addEventListener('click', () => { animateSeqBlockOut(div, () => { div.remove(); ensureAtLeastOneSeqBlock(); syncHiddenModalText(); refreshSeqBlockMoveButtons(); }); }); typeSel.addEventListener('change', () => { const ta2 = div.querySelector('.seq-block-seq'); const t = typeSel.value; if (ta2) { if (t === 'rna') ta2.value = String(ta2.value || '').toUpperCase().replace(/T/g, 'U'); if (t === 'dna') ta2.value = String(ta2.value || '').toUpperCase().replace(/U/g, 'T'); } updateSeqBlockLookupMode(div); syncHiddenModalText(); }); window.ViciLookup?.init?.(div); updateSeqBlockLookupMode(div); return div; } function updateSeqBlockLookupMode(block) { const type = block.querySelector('.seq-block-type')?.value || 'protein'; const lookupEl = block.querySelector('.vici-lookup'); if (!lookupEl) return; window.ViciLookup?.setMode?.(lookupEl, type); } function ensureAtLeastOneSeqBlock() { if (!seqBlocksWrap) return; if (!seqBlocksWrap.querySelector('.seq-block')) { addSeqBlock({ name: '', seq: '', type: 'protein' }); } } function addSeqBlock(data = {}, opts = {}) { if (!seqBlocksWrap) return; const modalOpen = Boolean(seqModalBackdrop && !seqModalBackdrop.hidden); const animate = Object.prototype.hasOwnProperty.call(opts, 'animate') ? Boolean(opts.animate) : modalOpen; const block = buildSeqBlock(data); seqBlocksWrap.appendChild(block); if (animate) animateSeqBlockIn(block); syncHiddenModalText(); refreshSeqBlockMoveButtons(); } function serializeSeqBlocksToFasta() { if (!seqBlocksWrap) return ''; const blocks = Array.from(seqBlocksWrap.querySelectorAll('.seq-block')); const parts = []; blocks.forEach((b, i) => { const nameRaw = (b.querySelector('.seq-block-name')?.value || '').trim(); const name = nameRaw || `Sequence ${i + 1}`; const type = b.querySelector('.seq-block-type')?.value || 'protein'; let seq = (b.querySelector('.seq-block-seq')?.value || '') .replace(/\s+/g, '') .toUpperCase(); if (!seq) return; if (type === 'rna') seq = seq.replace(/T/g, 'U'); if (type === 'dna') seq = seq.replace(/U/g, 'T'); parts.push(`>${name}`); const wrapped = seq.match(/.{1,80}/g)?.join('\n') || seq; parts.push(wrapped); parts.push(''); }); return parts.join('\n').trim(); } function syncHiddenModalText() { if (!seqModalInput) return; seqModalInput.value = serializeSeqBlocksToFasta(); } function populateSeqBlocksFromRaw(rawText) { if (!seqBlocksWrap) return; seqBlocksWrap.innerHTML = ''; const raw = String(rawText || '').trim(); const entries = (typeof parseEntriesFromRaw === 'function') ? parseEntriesFromRaw(raw) : []; if (!entries || !entries.length) { addSeqBlock({ name: '', seq: raw ? raw.replace(/[^A-Za-z]/g, '').toUpperCase() : '', type: 'protein' }); ensureAtLeastOneSeqBlock(); syncHiddenModalText(); return; } entries.forEach((e, i) => { const header = (e.header || '').replace(/^>\s*/, '').trim(); const name = header || `Sequence ${i + 1}`; const seq = (e.seq || '').toUpperCase(); let t = 'protein'; if (typeof detectSeqType === 'function') { const dt = detectSeqType(seq); if (dt === 'dna' || dt === 'rna' || dt === 'protein') t = dt; else if (dt === 'mixed') t = 'protein'; } addSeqBlock({ name, seq, type: t }, { animate: false }); }); ensureAtLeastOneSeqBlock(); syncHiddenModalText(); refreshSeqBlockMoveButtons(); } seqBlocksWrap?.addEventListener('input', (e) => { const t = e.target; if (!t) return; if (t.classList.contains('seq-block-name') || t.classList.contains('seq-block-seq')) { syncHiddenModalText(); } }); seqAddBlockBtn?.addEventListener('click', () => addSeqBlock({})); function escapeHtmlAttr(s){ return String(s || '') .replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>'); } function escapeHtmlText(s){ return String(s || '') .replace(/&/g, '&') .replace(//g, '>'); } const deleteBackdrop = document.getElementById('editor-delete-backdrop'); const deleteClose = document.getElementById('editor-delete-close'); const deleteCancel = document.getElementById('editor-delete-cancel'); const deleteConfirm = document.getElementById('editor-delete-confirm'); const deleteCountInput = document.getElementById('editor-delete-count-input'); const deleteCountLabel = document.getElementById('editor-delete-count-label'); const deleteText = document.getElementById('editor-delete-text'); const duplicateBackdrop = document.getElementById('editor-duplicate-backdrop'); const duplicateClose = document.getElementById('editor-duplicate-close'); const duplicateCancel = document.getElementById('editor-duplicate-cancel'); const duplicateConfirm = document.getElementById('editor-duplicate-confirm'); const duplicateInputsWrap = document.getElementById('editor-duplicate-inputs'); const searchInput = document.getElementById('seq-search-input'); const searchCount = document.getElementById('seq-search-count'); const mutateBtn = document.getElementById('seq-mutate-btn'); const insertLeftBtn = document.getElementById('seq-insert-left-btn'); const insertRightBtn = document.getElementById('seq-insert-right-btn'); function entriesToRawFallback(entries){ const parts = []; const multi = entries.length > 1; entries.forEach((e, i) => { if (e.header) parts.push(e.header); else if (multi) parts.push(`>Sequence ${i + 1}`); parts.push(String(e.seq || '').toUpperCase()); if (i < entries.length - 1) parts.push(''); }); return parts.join('\n').trim(); } function mainViewerInsert(where){ if (isLocked?.()) { notify?.('error', 'Unlock to edit'); return; } let ranges = (typeof getActiveRanges === 'function') ? getActiveRanges() : (selectionRanges || []); let track = selectionTrack || null; if ((!ranges || !ranges.length || !track) && lastGoodSelection?.ranges?.length) { ranges = lastGoodSelection.ranges; track = lastGoodSelection.track; } if (!ranges || !ranges.length) { notify?.('error', 'Select a residue/base first'); return; } const r = ranges[0]; const anchorPos = (where === 'before') ? r.start : r.end; const entries = parseEntriesFromRaw(seqRaw); if (!entries || !entries.length) { notify?.('error', 'No sequence loaded'); return; } let chainStart = 1; let hit = null; for (let i=0;i= start && anchorPos <= end){ hit = { i, start, end, seq }; break; } chainStart = end + 1; } if (!hit) return; const baseType = detectSeqType(hit.seq); const local = anchorPos - hit.start + 1; // 1-based const insertAt = (where === 'before') ? (local - 1) : local; // 0-based slice index const ins = (baseType === 'dna' || baseType === 'rna') ? 'N' : 'X'; const newGlobalPos = (where === 'before') ? anchorPos : (anchorPos + 1); const nextSeq = hit.seq.slice(0, insertAt) + ins + hit.seq.slice(insertAt); entries[hit.i].seq = nextSeq; const rawOut = (typeof entriesToRaw === 'function') ? entriesToRaw(entries) : entriesToRawFallback(entries); seqRaw = sanitizeAndGroup(rawOut).text; updateSeqViews(); updateSelectionToolbar(); updateEditButtonState?.(); lastState = seqRaw; lastState = seqRaw; lastState = seqRaw; lastState = seqRaw; if (typeof clearSelection === 'function') clearSelection(); if (typeof setSelection === 'function') { const t = track || getPrimarySelectableTrack?.() || 'protein'; selectionTrack = t; setSelection(newGlobalPos, newGlobalPos); } notify?.('success', 'Inserted'); } insertLeftBtn?.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); mainViewerInsert('before'); }, true); insertRightBtn?.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); mainViewerInsert('after'); }, true); let searchMatches = []; let searchNeedRefresh = false; if (!seqViewer || !lenEl || !posEl || !typePill) return; seqViewer.setAttribute('contenteditable', 'false'); let groupSize = 0; const groupModes = [0, 3, 5, 6, 9, 10, 15]; let groupModeIndex = 0; let resizeTimer = null; let lastSeq = ''; let undoStack = []; let redoStack = []; let isApplyingHistory = false; let lastState = ''; async function handleNewFileClick() { const has = (String(seqRaw || '').trim().length > 0) || viewerHasContent(); if (!has) { openSeqModal(''); return; } try { await saveCurrentFileBestEffort(); showToast?.('success', 'Saved'); } catch (_) {} clearSelection(); hardClearSearchUI(); seqRaw = ''; residueScoreMap = []; highlights.length = 0; undoStack = []; redoStack = []; lastState = ''; await nextFrame(); updateSeqViews(); updateSelectionToolbar(); updateEditButtonState(); } newFileBtn?.addEventListener('click', async (e) => { e.preventDefault(); if (!isMemberSignedIn) { redirectToSignUp(); return; } if (activeEditorTab === 'assets') { newTypeBackdrop.hidden = false; syncBodyScrollLock(); return; } if (activeEditorTab !== 'sequence') { showToast?.('info', `${activeEditorTab[0].toUpperCase()}${activeEditorTab.slice(1)} tools coming soon`); return; } if (hasSequenceWorkingState()) { return openSeqModal(seqRaw || ''); } handleNewFileClick(); }); function scheduleBackgroundAutoSave() { if (!currentAssetPath) return; if (autoSaveTimer) clearTimeout(autoSaveTimer); autoSaveTimer = setTimeout(async () => { autoSaveTimer = null; if (!hasUnsavedChanges || !currentAssetPath || autoSaveInFlight) return; autoSaveInFlight = saveCurrentSequenceToBackend().catch(() => {}).finally(() => { autoSaveInFlight = null; }); await autoSaveInFlight; }, 1200); } function markEditorDirty() { if (isApplyingPersistedState) return; hasUnsavedChanges = true; scheduleBackgroundAutoSave(); } nameInput?.addEventListener('input', () => { markEditorDirty(); }); let selectionRange = null; let selectionTrack = null; let didDragSelect = false; let dragAnchorStart = null; let dragPointer = null; let dragRaf = null; let trackCellIndex = {}; let lastSelectedCells = new Set(); document.addEventListener('mousemove', (e) => { if (dragAnchorStart == null) return; dragPointer = { x: e.clientX, y: e.clientY }; }); let selectionRanges = []; let lastGoodSelection = null; function rememberLastSelection() { const ranges = (typeof getActiveRanges === 'function') ? getActiveRanges() : []; if (!selectionTrack || !ranges || !ranges.length) return; lastGoodSelection = { track: selectionTrack, ranges: ranges.map(r => ({ start: r.start, end: r.end })) }; } let dragAppendIndex = null; let dragAppending = false; let suppressNextClick = false; let suppressSearchInput = false; let proteinColorMode = 'none'; let ntScheme = 'classic'; let fontSize = 22; const highlights = []; let residueScoreMap = []; let proteinViewMode = 1; let showRulers = true; let rulerEvery = 3; let showProtein = true; let showDNA = false; let showRNA = false; let showRevComp = false; let activeNucType = null; let lastNucType = 'dna'; let lastSearchQuery = ''; let lastSearchIndex = -1; let searchTimer = null; let selectionFromSearch = false; let lastAppliedSelectionTrack = null; let searchAuthoredByUser = false; let searchSelectionKey = ''; let searchSelectionCustom = false; let downRange = null; let downWasSelected = false; let dragStartPointer = null; let activeTouchId = null; function getSequencePersistenceState() { const ranges = (typeof getActiveRanges === 'function') ? getActiveRanges() : (selectionRanges || []); const codonOverrides = []; proteinCodonOverrides.forEach((value, key) => codonOverrides.push([key, value])); return { seqRaw, name: nameInput?.value || '', highlights: Array.isArray(highlights) ? JSON.parse(JSON.stringify(highlights)) : [], residueScoreMap: Array.isArray(residueScoreMap) ? residueScoreMap.slice() : [], selectionTrack, selectionRange: selectionRange ? { ...selectionRange } : null, selectionRanges: Array.isArray(ranges) ? ranges.map((r) => ({ ...r })) : [], proteinColorMode, ntScheme, proteinViewMode, showRulers, rulerEvery, showProtein, showDNA, showRNA, showRevComp, activeNucType, lastNucType, translationFrameOffset, translationAutoCycle, translationRanges, groupModeIndex, groupSize, fontSize, searchInputValue: searchInput?.value || '', codonOverrides, }; } function serializeSequenceEditorState(fileDisplayName) { return JSON.stringify({ schema_version: 1, editor_type: 'sequence', saved_at: new Date().toISOString(), file_display_name: fileDisplayName || '', sequence_state: getSequencePersistenceState(), }); } function applySequenceEditorState(state) { if (!state || typeof state !== 'object') return; isApplyingPersistedState = true; try { seqRaw = String(state.seqRaw || ''); if (nameInput) nameInput.value = String(state.name || ''); highlights.length = 0; (state.highlights || []).forEach((h) => highlights.push(h)); residueScoreMap = Array.isArray(state.residueScoreMap) ? state.residueScoreMap.slice() : []; proteinColorMode = String(state.proteinColorMode || 'none'); ntScheme = String(state.ntScheme || 'classic'); proteinViewMode = Number.isFinite(Number(state.proteinViewMode)) ? Number(state.proteinViewMode) : 1; showRulers = !!state.showRulers; rulerEvery = Number.isFinite(Number(state.rulerEvery)) ? Number(state.rulerEvery) : 3; showProtein = state.showProtein !== false; showDNA = !!state.showDNA; showRNA = !!state.showRNA; showRevComp = !!state.showRevComp; activeNucType = state.activeNucType || null; lastNucType = state.lastNucType || 'dna'; translationFrameOffset = Number.isFinite(Number(state.translationFrameOffset)) ? Number(state.translationFrameOffset) : 0; translationAutoCycle = !!state.translationAutoCycle; translationRanges = state.translationRanges || null; groupModeIndex = Number.isFinite(Number(state.groupModeIndex)) ? Number(state.groupModeIndex) : 0; groupSize = Number.isFinite(Number(state.groupSize)) ? Number(state.groupSize) : 0; fontSize = Number.isFinite(Number(state.fontSize)) ? Number(state.fontSize) : fontSize; proteinCodonOverrides.clear(); (state.codonOverrides || []).forEach((item) => { if (Array.isArray(item) && item.length === 2) proteinCodonOverrides.set(item[0], item[1]); }); selectionTrack = state.selectionTrack || null; selectionRange = state.selectionRange || null; selectionRanges = Array.isArray(state.selectionRanges) ? state.selectionRanges.map((r) => ({ ...r })) : []; if (searchInput) searchInput.value = String(state.searchInputValue || ''); updateSeqViews(); syncViewToggleButtons?.(); updateSelectionStyles?.(); updateSelectionToolbar?.(); updateMeta?.(); updateEditButtonState?.(); lastState = seqRaw; } finally { isApplyingPersistedState = false; } } function parseLegacyFasta(text) { const raw = String(text || ''); return { seqRaw: sanitizeAndGroup(raw).text, name: '', highlights: [], residueScoreMap: [], selectionTrack: null, selectionRange: null, selectionRanges: [], }; } function extractPersistedSequenceState(text) { const raw = String(text || '').trim(); if (!raw) return null; try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object' && parsed.sequence_state && typeof parsed.sequence_state === 'object') { return parsed.sequence_state; } if (typeof parsed?.editor_content?.file_content === 'string') { return extractPersistedSequenceState(parsed.editor_content.file_content); } if (typeof parsed?.file_content === 'string') { return extractPersistedSequenceState(parsed.file_content); } } catch (_) {} return null; } function syncBodyScrollLock() { const seqOpen = seqModalBackdrop && !seqModalBackdrop.hidden; const delOpen = deleteBackdrop && !deleteBackdrop.hidden; const mutOpen = mutModalBackdrop && !mutModalBackdrop.hidden; const open = seqOpen || delOpen || mutOpen; document.documentElement.classList.toggle('modal-open', open); document.body.classList.toggle('modal-open', open); } let __lastModalFocusEl = null; let __focusTrapCleanup = null; function __getFocusable(root) { if (!root) return []; const sel = [ 'button:not([disabled])', 'a[href]', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])' ].join(','); return Array.from(root.querySelectorAll(sel)).filter(el => { if (el.hidden) return false; // ignore elements that aren't actually visible return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); }); } function __trapFocus(dialogEl) { const onKeyDown = (e) => { if (e.key !== 'Tab') return; const focusables = __getFocusable(dialogEl); if (!focusables.length) return; const first = focusables[0]; const last = focusables[focusables.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }; document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); } function __transitionMs(el) { if (!el) return 0; const dur = getComputedStyle(el).transitionDuration || '0s'; const delays = dur.split(',').map(s => s.trim()).map(s => { if (s.endsWith('ms')) return parseFloat(s); if (s.endsWith('s')) return parseFloat(s) * 1000; return 0; }); return Math.max(0, ...delays, 0); } function openModalBackdrop(backdropEl, dialogEl) { if (!backdropEl) return; backdropEl.classList.remove('is-closing'); __lastModalFocusEl = document.activeElement; backdropEl.hidden = false; backdropEl.setAttribute('aria-hidden', 'false'); syncBodyScrollLock(); requestAnimationFrame(() => { backdropEl.classList.add('is-open'); }); __focusTrapCleanup?.(); __focusTrapCleanup = dialogEl ? __trapFocus(dialogEl) : null; requestAnimationFrame(() => { const focusables = dialogEl ? __getFocusable(dialogEl) : []; (focusables[0] || dialogEl || backdropEl).focus?.(); }); } function closeModalBackdrop(backdropEl, dialogEl, onClosed) { if (!backdropEl || backdropEl.hidden) return; const active = document.activeElement; if (dialogEl && active && dialogEl.contains(active)) { (document.body).focus(); } backdropEl.classList.remove('is-open'); backdropEl.classList.add('is-closing'); __focusTrapCleanup?.(); __focusTrapCleanup = null; syncBodyScrollLock(); const ms = __transitionMs(backdropEl); let done = false; const finish = () => { if (done) return; done = true; backdropEl.classList.remove('is-closing'); backdropEl.setAttribute('aria-hidden', 'true'); backdropEl.hidden = true; syncBodyScrollLock(); __lastModalFocusEl?.focus?.(); __lastModalFocusEl = null; onClosed?.(); }; const onEnd = (e) => { if (e.target !== backdropEl) return; backdropEl.removeEventListener('transitionend', onEnd); finish(); }; backdropEl.addEventListener('transitionend', onEnd); setTimeout(finish, Math.max(50, ms + 80)); // } function openSeqModal(prefillText = '') { if (!seqModalBackdrop) return; seqModalBackdrop.hidden = false; syncBodyScrollLock(); const txt = String(prefillText || ''); if (seqModalInput) seqModalInput.value = txt; populateSeqBlocksFromRaw(txt); refreshSeqBlockMoveButtons(); syncHiddenModalText(); ensureAtLeastOneSeqBlock(); requestAnimationFrame(() => { const first = seqBlocksWrap?.querySelector('.seq-block-name') || seqBlocksWrap?.querySelector('.seq-block-seq'); first?.focus?.(); }); } function closeSeqModal() { if (!seqModalBackdrop) return; seqModalBackdrop.hidden = true; syncBodyScrollLock(); } let mutDraft = null; let mutActive = null; let mutProtThree = false; let mutViewNuc = 'dna'; let mutRevEditable = true; let mutIndelAnchor = null; const mutViewProtBtn = document.getElementById('mut-view-prot'); const mutViewNucBtn = document.getElementById('mut-view-nuc'); const mutViewRevBtn = document.getElementById('mut-view-rev'); function mutSyncViewButtons(){ if (mutViewNucBtn) mutViewNucBtn.textContent = String(mutViewNuc || 'dna').toUpperCase(); mutViewProtBtn?.classList.toggle('is-active', true); mutViewNucBtn?.classList.toggle('is-active', true); mutViewRevBtn?.classList.toggle('is-active', !!mutRevEditable); } mutViewProtBtn?.addEventListener('click', () => { mutProtThree = !mutProtThree; mutSyncViewButtons(); if (mutDraft) mutRender(); }); mutViewNucBtn?.addEventListener('click', () => { mutViewNuc = (mutViewNuc === 'rna') ? 'dna' : 'rna'; mutSyncViewButtons(); if (mutDraft) mutRender(); }); mutViewRevBtn?.addEventListener('click', () => { mutRevEditable = !mutRevEditable; mutSyncViewButtons(); if (mutDraft) mutRender(); }); let mutMode = 'protein'; const MUT_ARROW_SVG = ` `; let mutUndoStack = []; let mutRedoStack = []; function mutSnapshot(){ if (!mutDraft) return null; return { mode: mutMode, entries: mutDraft.entries.map(e => ({ header: e.header || null, seq: String(e.seq || '') })), meta: mutDraft.meta.map(m => ({ i: m.i, header: m.header || null, baseType: m.baseType, chainStart: m.chainStart, chainLen: m.chainLen, segs: (m.segs || []).map(s => ({ start: s.start, end: s.end })), updated: Array.from(m.updated || []), inserted: Array.from(m.inserted || []), nucType: m.nucType || null, nucSeq: m.nucSeq || null, changeLog: Array.isArray(m.changeLog) ? m.changeLog.map(x => ({ ...x })) : [] })) }; } function mutRestore(snap){ if (!snap) return; mutMode = snap.mode || 'protein'; mutDraft = { entries: snap.entries.map(e => ({ header: e.header, seq: String(e.seq || '').toUpperCase() })), meta: snap.meta.map(m => ({ i: m.i, header: m.header, baseType: m.baseType, chainStart: m.chainStart, chainLen: m.chainLen, segs: (m.segs || []).map(s => ({ start: s.start, end: s.end })), updated: new Set(m.updated || []), inserted: new Set(m.inserted || []), nucType: m.nucType || null, nucSeq: m.nucSeq || null, changeLog: Array.isArray(m.changeLog) ? m.changeLog.map(x => ({ ...x })) : [] })) }; mutApplyModeUI(); } function mutPushHistory(){ const snap = mutSnapshot(); if (!snap) return; mutUndoStack.push(snap); mutRedoStack = []; } function mutUndo(){ if (!mutUndoStack.length) return; const cur = mutSnapshot(); if (cur) mutRedoStack.push(cur); const prev = mutUndoStack.pop(); mutRestore(prev); mutRender(); } function mutRedo(){ if (!mutRedoStack.length) return; const cur = mutSnapshot(); if (cur) mutUndoStack.push(cur); const next = mutRedoStack.pop(); mutRestore(next); mutRender(); } function mutApplyModeUI(){ const tabs = document.querySelectorAll('.mut-mode-tab'); tabs.forEach(btn => { const mode = btn.getAttribute('data-mut-mode'); const on = mode === mutMode; btn.classList.toggle('is-active', on); btn.setAttribute('aria-selected', on ? 'true' : 'false'); }); } function mutSetMode(nextMode){ mutMode = nextMode; mutApplyModeUI(); if (mutDraft) mutRender(); } document.getElementById('mut-mode-tabs')?.addEventListener('click', (e) => { const btn = e.target?.closest?.('.mut-mode-tab'); if (!btn) return; const mode = btn.getAttribute('data-mut-mode'); if (!mode) return; mutSetMode(mode); }); async function mutCopyActive(){ if (!mutActive || !mutDraft) return; const m = mutDraft.meta.find(x => x.i === mutActive.entryIndex); if (!m) return; const entry = mutDraft.entries[m.i]; const displayNucType = mutDisplayNucTypeFor(m.baseType); let txt = ''; if (mutActive.role === 'aa') { const idx = Number(mutActive.aaIndex || mutActive.codonIndex); txt = String(entry.seq[idx - 1] || ''); } else if (mutActive.role === 'codon') { const codonIndex = Number(mutActive.codonIndex); const frameOffset = Number.isFinite(mutActive.frameOffset) ? mutActive.frameOffset : mutFrameOffsetFor(m.baseType, m.chainStart); const nuc = (m.baseType === 'protein' || m.baseType === 'mixed' || m.baseType === 'unknown') ? String(m.nucSeq || backTranslateProtein(entry.seq, displayNucType)) : String((displayNucType === 'rna') ? toRNA(entry.seq) : toDNA(entry.seq)); const off = mutCodonOffsetFor(m.baseType, codonIndex, frameOffset); txt = nuc.slice(off, off + 3) || ''; } else if (mutActive.role === 'nt') { const codonIndex = Number(mutActive.codonIndex); const ntIndex = Number(mutActive.ntIndex); const frameOffset = Number.isFinite(mutActive.frameOffset) ? mutActive.frameOffset : mutFrameOffsetFor(m.baseType, m.chainStart); const nuc = (m.baseType === 'protein' || m.baseType === 'mixed' || m.baseType === 'unknown') ? String(m.nucSeq || backTranslateProtein(entry.seq, displayNucType)) : String((displayNucType === 'rna') ? toRNA(entry.seq) : toDNA(entry.seq)); const off = mutCodonOffsetFor(m.baseType, codonIndex, frameOffset) + ntIndex; txt = nuc[off] || ''; } if (!txt) return; if (typeof copyToClipboard === 'function') { copyToClipboard(txt, 'Copied'); return; } try { await navigator.clipboard.writeText(txt); notify?.('success', 'Copied'); } catch { notify?.('error', 'Copy failed'); } } function mutGetRanges() { if (typeof getActiveRanges === 'function') { const r = getActiveRanges(); return Array.isArray(r) ? r : []; } if (Array.isArray(selectionRanges) && selectionRanges.length) return selectionRanges; if (selectionRange) return [selectionRange]; return []; } function mutMergeSegsLocal(segs){ const cleaned = (segs || []) .map(s => ({ start: Math.min(s.start, s.end), end: Math.max(s.start, s.end), frameOffset: Number.isFinite(s.frameOffset) ? normFrameOffset(s.frameOffset) : null })) .filter(s => Number.isFinite(s.start) && Number.isFinite(s.end)) .sort((a,b) => (a.start - b.start) || ((a.frameOffset ?? -1) - (b.frameOffset ?? -1))); if (!cleaned.length) return []; const out = [cleaned[0]]; for (let i=1;i ({ start: Math.min(r.start, r.end), end: Math.max(r.start, r.end) })) .filter(r => Number.isFinite(r.start) && Number.isFinite(r.end)) .sort((a,b) => a.start - b.start); if (!cleaned.length) return []; const out = [cleaned[0]]; for (let i=1;i ({ header: e.header || null, seq: String(e.seq || '').toUpperCase() })); let chainStart = 1; const meta = []; const defaultFrameOffset = normFrameOffset(translationFrameOffset); const translationRangesByChain = Array.isArray(translationRanges) ? translationRanges .map(r => ({ ...r })) .filter(r => Number.isFinite(r.start) && Number.isFinite(r.end)) : []; const splitRangeByTranslation = (startGlobal, endGlobal, chainStart) => { if (startGlobal > endGlobal) return []; const chainRanges = translationRangesByChain .filter(r => Number(r.chainStart) === Number(chainStart)) .map(r => ({ start: Math.max(startGlobal, r.start), end: Math.min(endGlobal, r.end), frameOffset: normFrameOffset(r.frameOffset) })) .filter(r => r.start <= r.end) .sort((a, b) => a.start - b.start); if (!chainRanges.length) { return [{ start: startGlobal, end: endGlobal, frameOffset: defaultFrameOffset }]; } const out = []; let cursor = startGlobal; for (const r of chainRanges) { if (cursor < r.start) { out.push({ start: cursor, end: r.start - 1, frameOffset: defaultFrameOffset }); } out.push({ ...r }); cursor = r.end + 1; } if (cursor <= endGlobal) { out.push({ start: cursor, end: endGlobal, frameOffset: defaultFrameOffset }); } return mutMergeSegsLocal(out); }; for (let i = 0; i < draftEntries.length; i++) { const seq = draftEntries[i].seq; const baseType = detectSeqType(seq); const start = chainStart; const end = start + seq.length - 1; const segs = []; for (const r of ranges) { const s = Math.max(start, r.start); const e = Math.min(end, r.end); if (s > e) continue; if (baseType === 'dna' || baseType === 'rna') { const splits = splitRangeByTranslation(s, e, start); for (const seg of splits) { segs.push({ start: (seg.start - start + 1), end: (seg.end - start + 1), frameOffset: normFrameOffset(seg.frameOffset) }); } } else { segs.push({ start: (s - start + 1), end: (e - start + 1) }); } } if (segs.length) { meta.push({ i, header: draftEntries[i].header, baseType, chainStart: start, chainLen: seq.length, segs, updated: new Set(), inserted: new Set(), nucType: null, nucSeq: null, changeLog: [] }); } chainStart = end + 1; } if (!meta.length) return null; return { entries: draftEntries, meta }; } function mutOpen() { if (!mutModalBackdrop) return; const sm = mutSelectionMeta(); if (!sm) { notify?.('error', 'Select something first'); return; } mutDraft = sm; if (activeNucType === 'dna' || activeNucType === 'rna') { mutViewNuc = activeNucType; } else { mutViewNuc = mutDraft.meta.some(x => x.baseType === 'rna') ? 'rna' : 'dna'; } mutProtThree = (typeof proteinViewMode !== 'undefined' && proteinViewMode === 2); mutRevEditable = true; mutSyncViewButtons(); mutActive = null; mutUndoStack = []; mutRedoStack = []; if (mutDraft.meta.some(x => x.baseType === 'dna')) mutMode = 'dna'; else if (mutDraft.meta.some(x => x.baseType === 'rna')) mutMode = 'rna'; else mutMode = 'protein'; mutDraft.meta.forEach(m => { m.changeLog = []; }); openModalBackdrop(mutModalBackdrop, mutModalBackdrop.querySelector('.kb-modal--mut')); mutApplyModeUI(); mutRender(); } function mutClose() { if (!mutModalBackdrop) return; closeModalBackdrop( mutModalBackdrop, mutModalBackdrop.querySelector('.kb-modal--mut'), () => { mutDraft = null; mutActive = null; if (mutMiniViewer) mutMiniViewer.innerHTML = ''; if (mutOptions) mutOptions.innerHTML = ''; if (mutPicked) mutPicked.textContent = 'Tap a residue / codon base to edit.'; if (mutActions) mutActions.hidden = true; } ); } function mutCommit() { if (!mutDraft) return; const out = mutDraft.entries.map((e, entryIndex) => { const meta = mutDraft.meta?.find(m => m.i === entryIndex) || null; if (!meta) return { header: e.header, seq: e.seq }; const baseType = meta.baseType; const isProteinLike = (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown'); if (isProteinLike) { const nucType = (meta.nucType === 'rna' || meta.nucType === 'dna') ? meta.nucType : (mutViewNuc === 'rna' ? 'rna' : 'dna'); const prot = String(e.seq || '').toUpperCase(); const nucSeqRaw = String(meta.nucSeq || '').toUpperCase(); const key = entryKey({ header: e.header }, entryIndex); if (nucSeqRaw && nucSeqRaw.length === prot.length * 3) { const nucSeq = (nucType === 'rna') ? toRNA(nucSeqRaw) : toDNA(nucSeqRaw); const def = backTranslateProtein(prot, nucType); const hasOverride = toDNA(nucSeq) !== toDNA(def); if (hasOverride) proteinCodonOverrides.set(key, { nucType, seq: nucSeq }); else proteinCodonOverrides.delete(key); } else { proteinCodonOverrides.delete(key); } return { header: e.header, seq: prot }; } return { header: e.header, seq: String(e.seq || '').toUpperCase() }; }); const raw = entriesToRaw(out); const grouped = sanitizeAndGroup(raw); seqRaw = grouped.text; updateSeqViews(); updateSelectionToolbar(); updateEditButtonState?.(); mutClose(); notify?.('success', 'Saved'); } function mutMakeGapAA(){ const span = document.createElement('span'); span.className = 'seq-cell mut-gap'; span.textContent = '…'; span.setAttribute('aria-hidden','true'); return span; } function mutMakeGapCodon(){ const cell = document.createElement('div'); cell.className = 'seq-triplet-cell mut-gap'; cell.textContent = '…'; cell.setAttribute('aria-hidden','true'); return cell; } function mutTitleForHeader(h, idx){ const t = String(h || '').replace(/^>\s*/, '').trim(); return t || `Sequence ${idx + 1}`; } function mutApplyActiveHighlight() { if (!mutMiniViewer) return; mutMiniViewer .querySelectorAll('.mut-active, .selected') .forEach(x => { x.classList.remove('mut-active'); x.classList.remove('selected'); }); if (!mutActive) return; const entryIndex = String(mutActive.entryIndex); let sel = null; if (mutActive.role === 'aa') { if (mutActive.aaIndex != null) { sel = `[data-mut-role="aa"][data-entry-index="${entryIndex}"][data-aa-index="${mutActive.aaIndex}"]`; } else if (mutActive.codonIndex != null) { sel = `[data-mut-role="aa"][data-entry-index="${entryIndex}"][data-codon-index="${mutActive.codonIndex}"]`; } } else if (mutActive.role === 'codon') { sel = `[data-mut-role="codon"][data-entry-index="${entryIndex}"][data-codon-index="${mutActive.codonIndex}"]`; } else if (mutActive.role === 'nt') { sel = `[data-mut-role="nt"][data-entry-index="${entryIndex}"][data-codon-index="${mutActive.codonIndex}"][data-nt-index="${mutActive.ntIndex}"]`; } if (!sel) return; const el = mutMiniViewer.querySelector(sel); if (!el) return; el.classList.add('mut-active', 'selected'); el.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } function mutClearActiveSelection() { mutActive = null; mutIndelAnchor = null; mutApplyActiveHighlight(); mutRefreshUI(); if (mutActions) mutActions.hidden = true; } function mutRender() { if (!mutMiniViewer) return; mutMiniViewer.innerHTML = ''; if (!mutDraft || !mutDraft.meta?.length) return; mutMiniViewer.classList.toggle('is-prot-3', !!mutProtThree); const viewer = mutMiniViewer; const style = getComputedStyle(viewer); const cellSize = parseFloat(style.getPropertyValue('--seq-cell-size')) || 32; const gap = parseFloat(style.getPropertyValue('--seq-row-gap')) || 6; const groupGap = getNumberCssVarPx(viewer, '--seq-group-gap', 10); const labelW = getNumberCssVarPx(viewer, '--mut-label-w', 56); const pad = 8; const widthFor = (n) => { const spacers = (groupSize > 0) ? Math.floor((n - 1) / groupSize) : 0; const tracks = n + spacers; return (n * cellSize) + (spacers * groupGap) + ((tracks - 1) * gap); }; const viewerWidth = Math.max( viewer.clientWidth || 0, viewer.scrollWidth || 0, viewer.getBoundingClientRect().width || 0 ); const usable = Math.max(200, (viewerWidth || 900) - labelW - pad); let perLine = 1; for (let n = 1; n <= 400; n++) { if (widthFor(n) <= usable) perLine = n; else break; } perLine = Math.max(8, perLine); const makeGridTemplate = (n) => `${labelW}px ${buildRowTemplate(n)}`; const colForUnit = (i1) => gridColumnForUnitIndex(i1) + 1; const mutNucColorForHist = (base) => { const scheme = ntSchemes[ntScheme] || ntSchemes.classic; const b = String(base || '').toUpperCase().slice(0,1); return scheme[b] || scheme.N || '#9fb7d6'; }; const historyInfoFor = (m, pos, baseType, displayNucType) => { const items = Array.isArray(m.changeLog) ? m.changeLog : []; if (!items.length) return null; let insCount = 0, delCount = 0; let insSide = null, delSide = null; const tryAAFromFwdCodons = (fwdFrom, fwdTo) => { if (String(fwdFrom || '').length !== 3 || String(fwdTo || '').length !== 3) return null; const aaF = translateNuc(String(fwdFrom), displayNucType)[0] || 'X'; const aaT = translateNuc(String(fwdTo), displayNucType)[0] || 'X'; return (aaF !== aaT) ? { from: aaF, to: aaT } : null; }; let bestAA = null; let hasDirectAA = false; for (const c of items) { if (Number(c.pos) !== Number(pos)) continue; if (c.kind === 'ins') { insCount += (Number(c.count) || 1); insSide = c.side || insSide; continue; } if (c.kind === 'del') { delCount += (Number(c.count) || 1); delSide = c.side || delSide; continue; } if (c.kind === 'aa') { const from = String(c.from || 'X').toUpperCase().slice(0,1); const to = String(c.to || 'X').toUpperCase().slice(0,1); if (from !== to) { bestAA = { from, to }; hasDirectAA = true; } continue; } if (!hasDirectAA && (c.kind === 'codon' || c.kind === 'nt')) { const fwdFrom = c.fromFwdCodon || c.fromFwd; const fwdTo = c.toFwdCodon || c.toFwd; const aaMut = tryAAFromFwdCodons(fwdFrom, fwdTo); if (aaMut) bestAA = aaMut; } } if (!bestAA && !insCount && !delCount) return null; const mutation = bestAA ? { type: 'aa', from: bestAA.from, to: bestAA.to } : null; return { mutation, insCount, delCount, insSide, delSide }; }; const makePadAA = () => { const span = document.createElement('span'); span.className = 'seq-cell seq-cell--pad'; span.textContent = ''; span.setAttribute('aria-hidden','true'); return span; }; const makePadCodon = () => { const cell = document.createElement('div'); cell.className = 'seq-triplet-cell seq-cell--pad'; cell.textContent = ''; cell.setAttribute('aria-hidden','true'); return cell; }; const frag = document.createDocumentFragment(); for (const m of mutDraft.meta) { const entry = mutDraft.entries[m.i]; const baseType = m.baseType; const displayNucType = mutDisplayNucTypeFor(baseType); const section = document.createElement('div'); section.className = 'mut-section'; const title = document.createElement('div'); title.className = 'mut-section-title'; title.textContent = mutTitleForHeader(m.header, m.i); section.appendChild(title); const cols = []; m.segs.forEach((seg, si) => { if (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown') { for (let aa = seg.start; aa <= seg.end; aa++) cols.push({ kind:'pos', aaIndex: aa }); } else { const frameOffset = Number.isFinite(seg.frameOffset) ? seg.frameOffset : mutFrameOffsetFor(baseType, m.chainStart); const adjStart = (seg.start - 1) - frameOffset; const adjEnd = (seg.end - 1) - frameOffset; if (adjEnd >= 0) { const codonStart = Math.floor(Math.max(adjStart, 0) / 3) + 1; const codonEnd = Math.floor(Math.max(adjEnd, 0) / 3) + 1; for (let c = codonStart; c <= codonEnd; c++) { cols.push({ kind:'pos', codonIndex: c, frameOffset }); } } } if (si < m.segs.length - 1) cols.push({ kind:'gap' }); }); if (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown') { m.nucType = displayNucType; if (!m.nucSeq || m.nucSeq.length !== entry.seq.length * 3) { m.nucSeq = backTranslateProtein(entry.seq, displayNucType); } else { m.nucSeq = (displayNucType === 'rna') ? toRNA(m.nucSeq) : toDNA(m.nucSeq); } } const buildRowblock = (sliceRaw) => { const slice = sliceRaw.slice(); const n = Math.max(1, slice.length); const gridTemplate = makeGridTemplate(n); const rowblock = document.createElement('div'); rowblock.className = 'mut-rowblock'; const histRow = document.createElement('div'); histRow.className = 'mut-history-lane'; histRow.style.gridTemplateColumns = gridTemplate; const histLabel = document.createElement('div'); histLabel.className = 'mut-lane-label'; histLabel.textContent = ''; histRow.appendChild(histLabel); slice.forEach((c, i) => { const cell = document.createElement('div'); cell.className = 'mut-hist-cell'; cell.style.gridColumn = String(colForUnit(i + 1)); if (c.kind === 'pos') { const pos = (c.aaIndex != null) ? c.aaIndex : c.codonIndex; const info = historyInfoFor(m, pos, baseType, displayNucType); if (info?.mutation) { const wrap = document.createElement('span'); wrap.className = 'mut-hist-mutation'; const sFrom = document.createElement('span'); const sTo = document.createElement('span'); const arrowWrap = document.createElement('span'); arrowWrap.className = 'mut-hist-caret'; arrowWrap.textContent = '>'; const fromCh = info.mutation.from; const toCh = info.mutation.to; sFrom.textContent = fromCh; sTo.textContent = toCh; const globalPos = (Number(m.chainStart) || 1) + Number(pos) - 1; if (info.mutation.type === 'aa') { sFrom.style.setProperty('color', mutAAColorForHist(fromCh, globalPos), 'important'); sTo.style.setProperty('color', mutAAColorForHist(toCh, globalPos), 'important'); } else { sFrom.style.setProperty('color', mutNucColorForHist(fromCh), 'important'); sTo.style.setProperty('color', mutNucColorForHist(toCh), 'important'); } wrap.appendChild(sFrom); wrap.appendChild(arrowWrap); wrap.appendChild(sTo); cell.appendChild(wrap); } } histRow.appendChild(cell); }); rowblock.appendChild(histRow); // RULER const ruler = document.createElement('div'); ruler.className = 'seq-ruler-row'; ruler.style.gridTemplateColumns = gridTemplate; const rulerLabel = document.createElement('div'); rulerLabel.className = 'mut-lane-label'; rulerLabel.textContent = ''; ruler.appendChild(rulerLabel); slice.forEach((c, i) => { const tick = document.createElement('div'); tick.className = 'seq-tick'; tick.style.gridColumn = String(colForUnit(i + 1)); if (c.kind === 'pos') { const pos = (c.aaIndex != null) ? c.aaIndex : c.codonIndex; tick.textContent = String(pos); tick.classList.add('seq-tick--label'); } else { tick.classList.add('seq-tick--spacer'); tick.textContent = ''; } ruler.appendChild(tick); }); rowblock.appendChild(ruler); const laneProt = document.createElement('div'); laneProt.className = 'seq-row'; laneProt.style.gridTemplateColumns = gridTemplate; const laneNuc = document.createElement('div'); laneNuc.className = 'seq-row'; laneNuc.style.gridTemplateColumns = gridTemplate; const laneAnti = document.createElement('div'); laneAnti.className = 'seq-row'; laneAnti.style.gridTemplateColumns = gridTemplate; const labProt = document.createElement('div'); labProt.className = 'mut-lane-label'; labProt.textContent = 'Prot'; laneProt.appendChild(labProt); const labNuc = document.createElement('div'); labNuc.className = 'mut-lane-label'; labNuc.textContent = displayNucType.toUpperCase(); laneNuc.appendChild(labNuc); const labAnti = document.createElement('div'); labAnti.className = 'mut-lane-label'; labAnti.textContent = 'Comp'; laneAnti.appendChild(labAnti); const insertedSet = m.inserted || new Set(); if (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown') { const prot = entry.seq; const nuc = String(m.nucSeq || backTranslateProtein(prot, displayNucType)); slice.forEach((c, i) => { const gridCol = String(colForUnit(i + 1)); if (c.kind !== 'pos') { const isGap = (c.kind === 'gap'); const g1 = isGap ? mutMakeGapAA() : makePadAA(); const g2 = isGap ? mutMakeGapCodon() : makePadCodon(); const g3 = isGap ? mutMakeGapCodon() : makePadCodon(); g1.style.gridColumn = gridCol; g2.style.gridColumn = gridCol; g3.style.gridColumn = gridCol; laneProt.appendChild(g1); laneNuc.appendChild(g2); laneAnti.appendChild(g3); return; } const aaIndex = c.aaIndex; const aa = prot[aaIndex - 1] || 'X'; const aaCell = document.createElement('span'); aaCell.className = 'seq-cell'; aaCell.style.gridColumn = gridCol; aaCell.dataset.mutRole = 'aa'; aaCell.dataset.entryIndex = String(m.i); aaCell.dataset.aaIndex = String(aaIndex); aaCell.dataset.unit = aa; setCellText(aaCell, mutProtThree ? aaToThree(aa) : aa); if (insertedSet.has(aaIndex)) aaCell.classList.add('mut-inserted'); else if (m.updated.has(`aa:${aaIndex}`) || m.updated.has(`codon:${aaIndex}`)) aaCell.classList.add('mut-updated'); applyColorToResidue(aaCell, aa, (m.chainStart || 1) + aaIndex - 1); laneProt.appendChild(aaCell); const fwdCodon = (nuc.slice((aaIndex - 1) * 3, (aaIndex - 1) * 3 + 3) || '---').padEnd(3,'-'); const codonCell = document.createElement('div'); codonCell.className = 'seq-triplet-cell'; codonCell.style.gridColumn = gridCol; codonCell.dataset.mutRole = 'codon'; codonCell.dataset.entryIndex = String(m.i); codonCell.dataset.codonIndex = String(aaIndex); if (insertedSet.has(aaIndex)) codonCell.classList.add('mut-inserted'); else if (m.updated.has(`codon:${aaIndex}`)) codonCell.classList.add('mut-updated'); for (let k=0;k<3;k++){ const b = fwdCodon[k] || '-'; const mini = document.createElement('span'); mini.className = 'seq-triplet-nt'; mini.dataset.mutRole = 'nt'; mini.dataset.entryIndex = String(m.i); mini.dataset.codonIndex = String(aaIndex); mini.dataset.ntIndex = String(k); mini.textContent = b; applyColorToNuc(mini, b); codonCell.appendChild(mini); } laneNuc.appendChild(codonCell); const anti = antiCodonFromFwdCodon(fwdCodon, displayNucType); const antiCell = document.createElement('div'); antiCell.className = 'seq-triplet-cell'; antiCell.style.gridColumn = gridCol; antiCell.dataset.mutRole = 'codon'; antiCell.dataset.mutRev = '1'; antiCell.dataset.entryIndex = String(m.i); antiCell.dataset.codonIndex = String(aaIndex); if (!mutRevEditable) antiCell.dataset.mutReadonly = '1'; if (insertedSet.has(aaIndex)) antiCell.classList.add('mut-inserted'); for (let k=0;k<3;k++){ const b = anti[k] || '-'; const mini = document.createElement('span'); mini.className = 'seq-triplet-nt'; mini.dataset.mutRole = 'nt'; mini.dataset.mutRev = '1'; mini.dataset.entryIndex = String(m.i); mini.dataset.codonIndex = String(aaIndex); mini.dataset.ntIndex = String(k); if (!mutRevEditable) mini.dataset.mutReadonly = '1'; mini.textContent = b; applyColorToNuc(mini, b); antiCell.appendChild(mini); } laneAnti.appendChild(antiCell); }); } else { const base = entry.seq; const displaySeq = (displayNucType === 'rna') ? toRNA(base) : toDNA(base); slice.forEach((c, i) => { const gridCol = String(colForUnit(i + 1)); if (c.kind !== 'pos') { const isGap = (c.kind === 'gap'); const g1 = isGap ? mutMakeGapAA() : makePadAA(); const g2 = isGap ? mutMakeGapCodon() : makePadCodon(); const g3 = isGap ? mutMakeGapCodon() : makePadCodon(); g1.style.gridColumn = gridCol; g2.style.gridColumn = gridCol; g3.style.gridColumn = gridCol; laneProt.appendChild(g1); laneNuc.appendChild(g2); laneAnti.appendChild(g3); return; } const codonIndex = c.codonIndex; const frameOffset = Number.isFinite(c.frameOffset) ? c.frameOffset : mutFrameOffsetFor(baseType, m.chainStart); const codonOffset = frameOffset + ((codonIndex - 1) * 3); const fwdCodon = (displaySeq.slice(codonOffset, codonOffset + 3) || '---').padEnd(3,'-'); const aa = translateNuc(fwdCodon, displayNucType)[0] || 'X'; const aaCell = document.createElement('span'); aaCell.className = 'seq-cell'; aaCell.style.gridColumn = gridCol; aaCell.dataset.mutRole = 'aa'; aaCell.dataset.entryIndex = String(m.i); aaCell.dataset.codonIndex = String(codonIndex); aaCell.dataset.frameOffset = String(frameOffset); aaCell.dataset.unit = aa; setCellText(aaCell, mutProtThree ? aaToThree(aa) : aa); if (insertedSet.has(codonIndex)) aaCell.classList.add('mut-inserted'); else if (m.updated.has(`codon:${codonIndex}`)) aaCell.classList.add('mut-updated'); const residuePos = (m.chainStart || 1) + frameOffset + ((codonIndex - 1) * 3); applyColorToResidue(aaCell, aa, residuePos); laneProt.appendChild(aaCell); const codonCell = document.createElement('div'); codonCell.className = 'seq-triplet-cell'; codonCell.style.gridColumn = gridCol; codonCell.dataset.mutRole = 'codon'; codonCell.dataset.entryIndex = String(m.i); codonCell.dataset.codonIndex = String(codonIndex); codonCell.dataset.frameOffset = String(frameOffset); if (insertedSet.has(codonIndex)) codonCell.classList.add('mut-inserted'); else if (m.updated.has(`codon:${codonIndex}`)) codonCell.classList.add('mut-updated'); for (let k=0;k<3;k++){ const b = fwdCodon[k] || '-'; const mini = document.createElement('span'); mini.className = 'seq-triplet-nt'; mini.dataset.mutRole = 'nt'; mini.dataset.entryIndex = String(m.i); mini.dataset.codonIndex = String(codonIndex); mini.dataset.ntIndex = String(k); mini.dataset.frameOffset = String(frameOffset); mini.textContent = b; applyColorToNuc(mini, b); codonCell.appendChild(mini); } laneNuc.appendChild(codonCell); const anti = antiCodonFromFwdCodon(fwdCodon, displayNucType); const antiCell = document.createElement('div'); antiCell.className = 'seq-triplet-cell'; antiCell.style.gridColumn = gridCol; antiCell.dataset.mutRole = 'codon'; antiCell.dataset.mutRev = '1'; antiCell.dataset.entryIndex = String(m.i); antiCell.dataset.codonIndex = String(codonIndex); antiCell.dataset.frameOffset = String(frameOffset); if (!mutRevEditable) antiCell.dataset.mutReadonly = '1'; if (insertedSet.has(codonIndex)) antiCell.classList.add('mut-inserted'); for (let k=0;k<3;k++){ const b = anti[k] || '-'; const mini = document.createElement('span'); mini.className = 'seq-triplet-nt'; mini.dataset.mutRole = 'nt'; mini.dataset.mutRev = '1'; mini.dataset.entryIndex = String(m.i); mini.dataset.codonIndex = String(codonIndex); mini.dataset.ntIndex = String(k); mini.dataset.frameOffset = String(frameOffset); if (!mutRevEditable) mini.dataset.mutReadonly = '1'; mini.textContent = b; applyColorToNuc(mini, b); antiCell.appendChild(mini); } laneAnti.appendChild(antiCell); }); } addDirectionLabels(laneProt, 'N-', '-C', 'mut-direction-label'); addDirectionLabels(laneNuc, '5′', '3′', 'mut-direction-label'); addDirectionLabels(laneAnti, '3′', '5′', 'mut-direction-label'); rowblock.appendChild(laneProt); rowblock.appendChild(laneNuc); rowblock.appendChild(laneAnti); return rowblock; }; for (let i=0;i 200) undoStack.shift(); } if (Array.isArray(redoStack)) redoStack.length = 0; } catch (_) {} } function buildCodonGroups(nucType){ const map = {}; Object.keys(geneticCodeDNA || {}).forEach(dna => { const aa = geneticCodeDNA[dna] || 'X'; const key = aa; if (!map[key]) map[key] = []; map[key].push(normalizeCodonForType(dna, nucType)); }); const order = ['A','R','N','D','C','Q','E','G','H','I','L','K','M','F','P','S','T','W','Y','V','*','X']; const out = []; order.forEach(aa => { const codons = (map[aa] || []).slice().sort(); if (!codons.length) return; out.push({ aa, codons }); }); Object.keys(map).forEach(aa => { if (order.includes(aa)) return; out.push({ aa, codons: map[aa].slice().sort() }); }); return out; } function mutEnsureProteinNuc(m, entry, nucType){ m.nucType = nucType; if (!m.nucSeq || m.nucSeq.length !== entry.seq.length * 3) { m.nucSeq = backTranslateProtein(entry.seq, nucType); } else { m.nucSeq = (nucType === 'rna') ? toRNA(m.nucSeq) : toDNA(m.nucSeq); } } function mutShiftInsertedSet(set, insertPos){ const out = new Set(); for (const v of (set || [])) { const n = Number(v); if (!Number.isFinite(n)) continue; out.add(n >= insertPos ? (n + 1) : n); } return out; } function mutShiftInsertedSetAfterDelete(set, delIdx){ const out = new Set(); for (const v of (set || [])) { const n = Number(v); if (!Number.isFinite(n)) continue; if (n === delIdx) continue; out.add(n > delIdx ? (n - 1) : n); } return out; } function mutShiftUpdatedSetAfterInsert(updated, insertPos){ const out = new Set(); for (const key of (updated || [])) { const m = String(key).match(/^(aa|codon):(\d+)$/); if (!m) { out.add(key); continue; } const k = m[1]; const n = Number(m[2]); if (!Number.isFinite(n)) { out.add(key); continue; } const nn = (n >= insertPos) ? (n + 1) : n; out.add(`${k}:${nn}`); } return out; } function mutShiftUpdatedSetAfterDelete(updated, delIdx){ const out = new Set(); for (const key of (updated || [])) { const m = String(key).match(/^(aa|codon):(\d+)$/); if (!m) { out.add(key); continue; } const k = m[1]; const n = Number(m[2]); if (!Number.isFinite(n)) { out.add(key); continue; } if (n === delIdx) continue; const nn = (n > delIdx) ? (n - 1) : n; out.add(`${k}:${nn}`); } return out; } function mutCoalesceIndels(changeLog){ if (!Array.isArray(changeLog) || !changeLog.length) return changeLog || []; const map = new Map(); const out = []; for (const rec of changeLog) { const kind = rec?.kind; if (kind !== 'ins' && kind !== 'del') { out.push(rec); continue; } const side = (rec.side === 'before' || rec.side === 'after') ? rec.side : 'after'; const pos = Number(rec.pos); const key = `${kind}:${pos}:${side}`; const prev = map.get(key); if (!prev) { const next = { ...rec, side, pos, key, count: Number(rec.count) || 1 }; map.set(key, next); out.push(next); } else { prev.count = (Number(prev.count) || 1) + (Number(rec.count) || 1); } } return out; } function mutReindexChangeLogAfterInsert(changeLog, insertPos){ if (!Array.isArray(changeLog)) return []; for (const rec of changeLog) { const pos = Number(rec.pos); if (!Number.isFinite(pos)) continue; if (pos === insertPos && (rec.kind === 'ins' || rec.kind === 'del') && rec.side === 'before') { rec.pos = pos; } else if (pos >= insertPos) { rec.pos = pos + 1; } if (rec.kind === 'ins' || rec.kind === 'del') { const side = (rec.side === 'before' || rec.side === 'after') ? rec.side : 'after'; rec.side = side; rec.key = `${rec.kind}:${Number(rec.pos)}:${side}`; } } return mutCoalesceIndels(changeLog); } function mutReindexChangeLogAfterDelete(changeLog, delIdx){ if (!Array.isArray(changeLog)) return []; const kept = []; for (const rec of changeLog) { const pos = Number(rec.pos); if (!Number.isFinite(pos)) { kept.push(rec); continue; } if ((rec.kind === 'aa' || rec.kind === 'codon' || rec.kind === 'nt') && pos === delIdx) { continue; } if (pos > delIdx) { rec.pos = pos - 1; } else if (pos === delIdx && (rec.kind === 'ins' || rec.kind === 'del')) { if (rec.side === 'after') rec.pos = Math.max(1, delIdx - 1); else rec.pos = delIdx; } if (rec.kind === 'ins' || rec.kind === 'del') { const side = (rec.side === 'before' || rec.side === 'after') ? rec.side : 'after'; rec.side = side; rec.key = `${rec.kind}:${Number(rec.pos)}:${side}`; } kept.push(rec); } return mutCoalesceIndels(kept); } function mutLogChange(m, rec){ if (!m.changeLog) m.changeLog = []; const kind = String(rec.kind || ''); const pos = Number(rec.pos); if (kind === 'ins' || kind === 'del') { rec.side = (rec.side === 'before' || rec.side === 'after') ? rec.side : 'after'; rec.count = Number(rec.count) || 1; rec.key = rec.key || `${kind}:${pos}:${rec.side}`; } else { const sub = (rec.sub != null) ? String(rec.sub) : ''; const lane = (rec.onRev ? 'anti' : 'fwd'); rec.key = rec.key || `${kind}:${pos}:${sub}:${lane}`; } const idx = m.changeLog.findIndex(x => x.key === rec.key); if (idx >= 0) { const prev = m.changeLog[idx]; if ((kind === 'ins' || kind === 'del') && Number(prev.pos) === pos) { const prevCount = Number(prev.count) || 1; const add = Number(rec.count) || 1; m.changeLog[idx] = { ...prev, ...rec, count: prevCount + add }; return; } m.changeLog[idx] = { ...prev, ...rec }; return; } m.changeLog.push({ ...rec }); } function mutRefreshUI(){ if (!mutPicked || !mutOptions) return; if (!mutActive){ mutPicked.textContent = 'Tap a residue / codon base to edit.'; mutOptions.innerHTML = ''; mutOptions.classList.remove('is-open'); if (mutActions) mutActions.hidden = true; return; } const m = mutDraft?.meta?.find(x => x.i === mutActive.entryIndex); const baseType = m?.baseType || 'unknown'; const displayNucType = mutDisplayNucTypeFor(baseType); const onRev = !!mutActive.rev; if (mutActive.role === 'aa') { const idx = (mutActive.aaIndex || mutActive.codonIndex || '?'); mutPicked.textContent = `Residue ${idx}`; } else if (mutActive.role === 'nt') { mutPicked.textContent = `${onRev ? 'Comp codon' : 'Codon'} ${mutActive.codonIndex} base ${Number(mutActive.ntIndex) + 1}`; } else { mutPicked.textContent = `${onRev ? 'Comp codon' : 'Codon'} ${mutActive.codonIndex}`; } if (mutActions) mutActions.hidden = false; mutOptions.classList.remove('is-open'); mutOptions.innerHTML = ''; const isProteinLike = (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown'); const renderAAOptions = () => { const aas = [ 'V','I','L','M','F','Y','W', 'A','G','P','C', 'S','T','N','Q', 'D','E','K','R','H', 'X','*' ]; const wrap = document.createElement('div'); wrap.className = 'mut-opt-group'; wrap.innerHTML = `
Amino acids
${isProteinLike ? 'Edits AA + assigns default codon' : 'Edits via codon defaults'}
`; const grid = document.createElement('div'); grid.className = 'mut-opt-grid'; aas.forEach(v => { const b = document.createElement('button'); b.type = 'button'; b.className = 'mut-opt'; b.textContent = mutProtThree ? aaToThree(v) : v; b.dataset.aaGroup = aaGroupOf(v); b.addEventListener('click', () => mutApply(v)); grid.appendChild(b); }); wrap.appendChild(grid); mutOptions.appendChild(wrap); }; const renderCodonOptionsCompact = () => { const groups = buildCodonGroups(displayNucType); const wrap = document.createElement('div'); wrap.className = 'mut-opt-group'; wrap.innerHTML = `
${onRev ? 'Comp codons' : 'Codons'}
${displayNucType.toUpperCase()}
`; const table = document.createElement('div'); table.className = 'mut-codon-table'; { const row = document.createElement('div'); row.className = 'mut-codon-row'; const aa = document.createElement('div'); aa.className = 'mut-codon-aa'; aa.textContent = '—'; const codons = document.createElement('div'); codons.className = 'mut-codon-codons'; const b = document.createElement('button'); b.type = 'button'; b.className = 'mut-opt mut-opt--codon'; b.textContent = '---'; b.dataset.aaGroup = 'g0'; b.addEventListener('click', () => mutApply('---')); codons.appendChild(b); row.appendChild(aa); row.appendChild(codons); table.appendChild(row); } groups.forEach(g => { const row = document.createElement('div'); row.className = 'mut-codon-row'; const aa = document.createElement('div'); aa.className = 'mut-codon-aa'; aa.textContent = (g.aa === '*') ? 'STOP' : (mutProtThree ? aaToThree(g.aa) : g.aa); const codons = document.createElement('div'); codons.className = 'mut-codon-codons'; g.codons.forEach(c => { const shown = onRev ? antiCodonFromFwdCodon(c, displayNucType) : c; const b = document.createElement('button'); b.type = 'button'; b.className = 'mut-opt mut-opt--codon'; b.textContent = shown; b.dataset.aaGroup = aaGroupOf(g.aa); b.addEventListener('click', () => mutApply(shown)); codons.appendChild(b); }); row.appendChild(aa); row.appendChild(codons); table.appendChild(row); }); wrap.appendChild(table); mutOptions.appendChild(wrap); }; const renderNtOptions = () => { const opts = (displayNucType === 'rna') ? ['A','C','G','U','N','-'] : ['A','C','G','T','N','-']; const wrap = document.createElement('div'); wrap.className = 'mut-opt-group'; wrap.innerHTML = `
Bases
${displayNucType.toUpperCase()} ${onRev ? '(Comp view)' : ''}
`; const grid = document.createElement('div'); grid.className = 'mut-opt-grid'; opts.forEach(v => { const b = document.createElement('button'); b.type = 'button'; b.className = 'mut-opt'; b.textContent = v; b.dataset.aaGroup = 'g0'; b.addEventListener('click', () => mutApply(v)); grid.appendChild(b); }); wrap.appendChild(grid); mutOptions.appendChild(wrap); }; if (mutActive.role === 'aa') renderAAOptions(); else if (mutActive.role === 'codon') renderCodonOptionsCompact(); else if (mutActive.role === 'nt') renderNtOptions(); requestAnimationFrame(() => { mutOptions.classList.add('is-open'); }); } function mutApply(value){ if (!mutDraft || !mutActive) return; mutPushHistory(); const m = mutDraft.meta.find(x => x.i === mutActive.entryIndex); if (!m) return; const entry = mutDraft.entries[m.i]; const baseType = m.baseType; const displayNucType = mutDisplayNucTypeFor(baseType); const isProteinLike = (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown'); const isNucLike = (baseType === 'dna' || baseType === 'rna'); const onRev = !!mutActive.rev; const activeFrameOffset = Number.isFinite(mutActive.frameOffset) ? mutActive.frameOffset : mutFrameOffsetFor(baseType, m.chainStart); const normalizeNtChar = (ch) => { const up = String(ch || 'N').toUpperCase().slice(0,1); if (displayNucType === 'rna') return (up === 'T') ? 'U' : up; return (up === 'U') ? 'T' : up; }; const normalizeCodon = (codon) => { let c = String(codon || '---').toUpperCase().replace(/[^ACGTU\-N]/g,'-'); c = c.padEnd(3,'-').slice(0,3); if (displayNucType === 'rna') c = c.replace(/T/g,'U'); else c = c.replace(/U/g,'T'); return c; }; const getForwardDisp = () => { if (isProteinLike) { mutEnsureProteinNuc(m, entry, displayNucType); return String(m.nucSeq || ''); } const disp = (displayNucType === 'rna') ? toRNA(entry.seq) : toDNA(entry.seq); return String(disp || ''); }; const setForwardDisp = (nextDisp) => { if (isProteinLike) { m.nucSeq = nextDisp; entry.seq = translateNuc(m.nucSeq, displayNucType); return; } entry.seq = (baseType === 'rna') ? toRNA(nextDisp) : toDNA(nextDisp); }; const logDerivedAAFromCodons = (pos, fromFwdCodon, toFwdCodon) => { const f = String(fromFwdCodon || '---').toUpperCase().padEnd(3,'-').slice(0,3); const t = String(toFwdCodon || '---').toUpperCase().padEnd(3,'-').slice(0,3); const fromAA = translateNuc(f, displayNucType)[0] || 'X'; const toAA = translateNuc(t, displayNucType)[0] || 'X'; if (fromAA !== toAA) { m.updated.add(`aa:${pos}`); mutLogChange(m, { key:`aa:${pos}`, kind:'aa', pos, from:fromAA, to:toAA }); } }; if (mutActive.role === 'aa') { const aa = String(value || 'X').toUpperCase().slice(0,1); const idx = Number(mutActive.aaIndex || mutActive.codonIndex); if (!Number.isFinite(idx) || idx < 1) return; if (isProteinLike) { mutEnsureProteinNuc(m, entry, displayNucType); const fromAA = entry.seq[idx - 1] || 'X'; entry.seq = entry.seq.slice(0, idx - 1) + aa + entry.seq.slice(idx); const codonDNA = backCodonDNA?.[aa] || 'NNN'; const codon = normalizeCodonForType(codonDNA, displayNucType); const off = (idx - 1) * 3; const fromCodon = (m.nucSeq || '').slice(off, off + 3) || '---'; m.nucSeq = (m.nucSeq || '').slice(0, off) + codon + (m.nucSeq || '').slice(off + 3); m.updated.add(`aa:${idx}`); m.updated.add(`codon:${idx}`); mutLogChange(m, { key:`aa:${idx}`, kind:'aa', pos:idx, from:fromAA, to:aa }); mutLogChange(m, { key:`codon:${idx}:fwd`, kind:'codon', pos:idx, from:fromCodon, to:codon, onRev:false, fromFwd:fromCodon, toFwd:codon }); mutRender(); return; } if (isNucLike) { const codonIndex = idx; const disp = getForwardDisp(); const off = mutCodonOffsetFor(m.baseType, codonIndex, activeFrameOffset); const fromFwdCodon = (disp.slice(off, off + 3) || '---').padEnd(3,'-'); const codonDNA = backCodonDNA?.[aa] || 'NNN'; const codon = normalizeCodonForType(codonDNA, displayNucType); const next = disp.slice(0, off) + codon + disp.slice(off + 3); setForwardDisp(next); m.updated.add(`codon:${codonIndex}`); logDerivedAAFromCodons(codonIndex, fromFwdCodon, codon); mutLogChange(m, { key:`codon:${codonIndex}:fwd`, kind:'codon', pos:codonIndex, from:fromFwdCodon, to:codon, onRev:false, fromFwd:fromFwdCodon, toFwd:codon }); mutRender(); return; } mutRender(); return; } if (mutActive.role === 'nt') { const codonIndex = Number(mutActive.codonIndex); const ntIndexDisp = Number(mutActive.ntIndex); if (!Number.isFinite(codonIndex) || codonIndex < 1 || !Number.isFinite(ntIndexDisp)) return; const disp = getForwardDisp(); const codonOff = mutCodonOffsetFor(m.baseType, codonIndex, activeFrameOffset); const fwdNtIndex = onRev ? antiIndexToFwdIndex(ntIndexDisp) : ntIndexDisp; const off = codonOff + fwdNtIndex; const fromFwdCodon = (disp.slice(codonOff, codonOff + 3) || '---').padEnd(3,'-'); const fromAntiCodon = antiCodonFromFwdCodon(fromFwdCodon, displayNucType); const chDisp = normalizeNtChar(value); const chFwd = onRev ? complementBase(chDisp, displayNucType) : chDisp; const fromDispBase = onRev ? (fromAntiCodon[ntIndexDisp] || 'N') : (disp[off] || 'N'); const arr = disp.split(''); arr[off] = chFwd; const nextDisp = arr.join(''); const toFwdCodonArr = fromFwdCodon.split(''); toFwdCodonArr[fwdNtIndex] = chFwd; const toFwdCodon = toFwdCodonArr.join(''); setForwardDisp(nextDisp); m.updated.add(`codon:${codonIndex}`); logDerivedAAFromCodons(codonIndex, fromFwdCodon, toFwdCodon); mutLogChange(m, { key:`nt:${codonIndex}:${ntIndexDisp}:${onRev ? 'anti' : 'fwd'}`, kind:'nt', pos:codonIndex, sub:(ntIndexDisp + 1), from:fromDispBase, to:chDisp, onRev, fromFwdCodon, toFwdCodon }); mutRender(); return; } if (mutActive.role === 'codon') { const codonIndex = Number(mutActive.codonIndex); if (!Number.isFinite(codonIndex) || codonIndex < 1) return; const disp = getForwardDisp(); const codonDisp = normalizeCodon(value); const off = mutCodonOffsetFor(m.baseType, codonIndex, activeFrameOffset); const fromFwdCodon = (disp.slice(off, off + 3) || '---').padEnd(3,'-'); const fromDispCodon = onRev ? antiCodonFromFwdCodon(fromFwdCodon, displayNucType) : fromFwdCodon; const toFwdCodon = onRev ? complementSeq(codonDisp, displayNucType) : codonDisp; const next = disp.slice(0, off) + toFwdCodon + disp.slice(off + 3); setForwardDisp(next); m.updated.add(`codon:${codonIndex}`); logDerivedAAFromCodons(codonIndex, fromFwdCodon, toFwdCodon); mutLogChange(m, { key:`codon:${codonIndex}:${onRev ? 'anti' : 'fwd'}`, kind:'codon', pos:codonIndex, from:fromDispCodon, to:codonDisp, onRev, fromFwd:fromFwdCodon, toFwd:toFwdCodon }); mutRender(); return; } } mutMiniViewer?.addEventListener('click', (e) => { const target = e.target; mutIndelAnchor = null; let el = target.closest?.('[data-mut-role="nt"]') || target.closest?.('[data-mut-role="aa"]') || target.closest?.('[data-mut-role="codon"]'); if (!el) { mutClearActiveSelection(); return; } if (el.dataset.mutRole === 'nt' && !e.shiftKey) { const parentCodon = el.closest?.('[data-mut-role="codon"]'); if (parentCodon) el = parentCodon; } mutMiniViewer.querySelectorAll('.mut-active, .selected').forEach(x => { x.classList.remove('mut-active'); x.classList.remove('selected'); }); el.classList.add('mut-active', 'selected'); const isReadonly = (el.dataset.mutReadonly === '1'); if (isReadonly) { notify?.('error', 'This part is read-only'); return; } const entryIndex = Number(el.dataset.entryIndex); const role = el.dataset.mutRole; mutActive = { entryIndex, role }; mutActive.rev = (el.dataset.mutRev === '1'); if (el.dataset.frameOffset) mutActive.frameOffset = Number(el.dataset.frameOffset); if (role === 'aa') { if (el.dataset.aaIndex) mutActive.aaIndex = Number(el.dataset.aaIndex); if (el.dataset.codonIndex) mutActive.codonIndex = Number(el.dataset.codonIndex); } else { mutActive.codonIndex = Number(el.dataset.codonIndex); if (role === 'nt') mutActive.ntIndex = Number(el.dataset.ntIndex); } mutRefreshUI(); }); function mutShiftSegs(m, atLocal, delta){ const next = []; for (const seg of m.segs) { let s = seg.start, e = seg.end; const frameOffset = Number.isFinite(seg.frameOffset) ? seg.frameOffset : null; if (e < atLocal) { next.push({ start: s, end: e, frameOffset }); continue; } if (s >= atLocal) { s += delta; e += delta; } else { e += delta; } if (e >= s) next.push({ start: s, end: e, frameOffset }); } m.segs = next; } function mutInsert(where){ if (!mutDraft || !mutActive) return; mutPushHistory(); const m = mutDraft.meta.find(x => x.i === mutActive.entryIndex); const entry = mutDraft.entries[m.i]; const baseType = m.baseType; if (!m.inserted) m.inserted = new Set(); const isProteinLike = (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown'); const side = (where === 'before') ? 'before' : 'after'; const curIndex = isProteinLike ? Number(mutActive.aaIndex || mutActive.codonIndex) : Number(mutActive.codonIndex); if (!Number.isFinite(curIndex) || curIndex < 1) return; const unitKind = isProteinLike ? 'aa' : 'codon'; if (!mutIndelAnchor || mutIndelAnchor.entryIndex !== m.i || mutIndelAnchor.side !== side || mutIndelAnchor.unit !== unitKind) { mutIndelAnchor = { entryIndex: m.i, side, pos: curIndex, unit: unitKind }; } const anchorPos = Number(mutIndelAnchor.pos) || curIndex; if (isProteinLike) { const idx = curIndex; const insertPos = (where === 'after') ? (idx + 1) : idx; const insPos0 = (where === 'after') ? idx : (idx - 1); m.inserted = mutShiftInsertedSet(m.inserted, insertPos); m.updated = mutShiftUpdatedSetAfterInsert(m.updated, insertPos); m.changeLog = mutReindexChangeLogAfterInsert(m.changeLog, insertPos); const shiftFrom = insertPos; mutShiftSegs(m, shiftFrom, 1); const displayNucType = mutDisplayNucTypeFor(baseType); mutEnsureProteinNuc(m, entry, displayNucType); entry.seq = entry.seq.slice(0, insPos0) + 'X' + entry.seq.slice(insPos0); const offNt = insPos0 * 3; const insCodon = (displayNucType === 'rna') ? 'NNN'.replace(/T/g,'U') : 'NNN'; m.nucSeq = m.nucSeq.slice(0, offNt) + insCodon + m.nucSeq.slice(offNt); mutEnsureSegIncludes(m, insertPos, insertPos); m.inserted.add(insertPos); mutLogChange(m, { kind:'ins', pos: anchorPos, side, count: 1 }); mutActive = { entryIndex: m.i, role:'aa', aaIndex: insertPos, rev:false }; mutRender(); return; } const codonIndex = curIndex; const insertCodonPos = (where === 'after') ? (codonIndex + 1) : codonIndex; const displayNucType = mutDisplayNucTypeFor(baseType); const disp = (displayNucType === 'rna') ? toRNA(entry.seq) : toDNA(entry.seq); const frameOffset = Number.isFinite(mutActive.frameOffset) ? mutActive.frameOffset : mutFrameOffsetFor(baseType, m.chainStart); m.inserted = mutShiftInsertedSet(m.inserted, insertCodonPos); m.updated = mutShiftUpdatedSetAfterInsert(m.updated, insertCodonPos); m.changeLog = mutReindexChangeLogAfterInsert(m.changeLog, insertCodonPos); const insCodon = (displayNucType === 'rna') ? 'NNN'.replace(/T/g,'U') : 'NNN'; const baseOffset = mutCodonOffsetFor(m.baseType, codonIndex, frameOffset); const off = (where === 'after') ? (baseOffset + 3) : baseOffset; const next = disp.slice(0, off) + insCodon + disp.slice(off); entry.seq = (baseType === 'rna') ? toRNA(next) : toDNA(next); const atLocalNt = off + 1; mutShiftSegs(m, atLocalNt + 3, 3); mutEnsureSegIncludes(m, atLocalNt, atLocalNt + 2, frameOffset); m.inserted.add(insertCodonPos); mutLogChange(m, { kind:'ins', pos: anchorPos, side, count: 1 }); mutActive = { entryIndex: m.i, role:'codon', codonIndex: insertCodonPos, rev:false, frameOffset }; mutRender(); } function mutDelete(){ if (!mutDraft || !mutActive) return; mutPushHistory(); const m = mutDraft.meta.find(x => x.i === mutActive.entryIndex); const entry = mutDraft.entries[m.i]; const baseType = m.baseType; if (!m.inserted) m.inserted = new Set(); const isProteinLike = (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown'); mutIndelAnchor = null; if (isProteinLike) { const idx = Number(mutActive.aaIndex || mutActive.codonIndex); if (!Number.isFinite(idx) || idx < 1 || idx > entry.seq.length) return; const displayNucType = mutDisplayNucTypeFor(baseType); mutEnsureProteinNuc(m, entry, displayNucType); entry.seq = entry.seq.slice(0, idx - 1) + entry.seq.slice(idx); const offNt = (idx - 1) * 3; m.nucSeq = m.nucSeq.slice(0, offNt) + m.nucSeq.slice(offNt + 3); m.inserted = mutShiftInsertedSetAfterDelete(m.inserted, idx); m.updated = mutShiftUpdatedSetAfterDelete(m.updated, idx); m.changeLog = mutReindexChangeLogAfterDelete(m.changeLog, idx); mutShiftSegs(m, idx, -1); const anchorPos = (idx > 1) ? (idx - 1) : 1; const side = (idx > 1) ? 'after' : 'before'; mutLogChange(m, { kind:'del', pos: anchorPos, side, count: 1 }); mutRender(); return; } const codonIndex = Number(mutActive.codonIndex); if (!Number.isFinite(codonIndex) || codonIndex < 1) return; const displayNucType = mutDisplayNucTypeFor(baseType); const disp = (displayNucType === 'rna') ? toRNA(entry.seq) : toDNA(entry.seq); const frameOffset = Number.isFinite(mutActive.frameOffset) ? mutActive.frameOffset : mutFrameOffsetFor(baseType, m.chainStart); const off = mutCodonOffsetFor(m.baseType, codonIndex, frameOffset); if (off + 3 > disp.length) return; const next = disp.slice(0, off) + disp.slice(off + 3); entry.seq = (baseType === 'rna') ? toRNA(next) : toDNA(next); m.inserted = mutShiftInsertedSetAfterDelete(m.inserted, codonIndex); m.updated = mutShiftUpdatedSetAfterDelete(m.updated, codonIndex); m.changeLog = mutReindexChangeLogAfterDelete(m.changeLog, codonIndex); const atLocalNt = off + 1; mutShiftSegs(m, atLocalNt, -3); const anchorPos = (codonIndex > 1) ? (codonIndex - 1) : 1; const side = (codonIndex > 1) ? 'after' : 'before'; mutLogChange(m, { kind:'del', pos: anchorPos, side, count: 1 }); mutRender(); } mutUndoBtn?.addEventListener('click', mutUndo); mutRedoBtn?.addEventListener('click', mutRedo); mutCopyBtn?.addEventListener('click', mutCopyActive); mutInsertLeft?.addEventListener('click', () => mutInsert('before')); mutInsertRight?.addEventListener('click', () => mutInsert('after')); mutDeleteBtn?.addEventListener('click', mutDelete); mutModalClose?.addEventListener('click', mutClose); mutModalCancel?.addEventListener('click', mutClose); mutModalSave?.addEventListener('click', mutCommit); function mutIsInteractiveTarget(t) { return !!t.closest?.('button, a, input, textarea, select, label, [role="button"]'); } mutModalBackdrop?.addEventListener('mousedown', (e) => { if (!mutModalBackdrop || mutModalBackdrop.hidden) return; if (e.target === mutModalBackdrop) return; if (!mutMiniViewer) return; if (e.target.closest?.('#mut-mini-viewer')) return; if (mutIsInteractiveTarget(e.target)) return; const inModal = e.target.closest?.('.kb-modal, .kb-modal-body'); if (!inModal) return; mutClearActiveSelection(); }); mutModalBackdrop?.addEventListener('click', (e) => { if (e.target === mutModalBackdrop) mutClose(); }); mutateBtn?.addEventListener('click', () => { const ranges = mutGetRanges(); if (!ranges.length) { notify?.('error', 'Select residues / bases first'); return; } mutOpen(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && mutModalBackdrop && !mutModalBackdrop.hidden) { mutClose(); } }); function bindAssetsPanelFileDrop(assetsPanel) { if (!assetsPanel || assetsPanel.dataset.dropBound === '1') return; assetsPanel.dataset.dropBound = '1'; const preventDropDefault = (e) => { e.preventDefault(); e.stopPropagation(); }; ['dragenter', 'dragover'].forEach((evt) => { assetsPanel.addEventListener(evt, (e) => { preventDropDefault(e); assetsPanel.classList.add('is-dragover'); }); }); ['dragleave', 'drop'].forEach((evt) => { assetsPanel.addEventListener(evt, (e) => { preventDropDefault(e); assetsPanel.classList.remove('is-dragover'); }); }); assetsPanel.addEventListener('drop', async (e) => { if (!isMemberSignedIn) return; const files = Array.from(e.dataTransfer?.files || []); if (!files.length) return; try { const { jwt, payload } = await getEditorAuthContext(); setPanelOverlay(true, files.length > 1 ? 'Uploading files' : `Uploading ${files[0].name}`); for (const file of files) { const fileContent = await readFileAsText(file); const fileName = String(file.name || `asset-${Date.now()}.txt`).trim(); const fileExtension = (getFileExtension(fileName) || 'txt').toLowerCase(); await saveEditorAsset(payload, { editor_content: { file_name: fileName, file_extension: fileExtension, file_content: fileContent, ui_state: null, } }, jwt); } showToast?.('success', files.length > 1 ? 'Files uploaded' : 'File uploaded'); await refreshAssetsForCurrentContext(); } catch (err) { showToast?.('error', err?.message || 'Failed to upload files'); } finally { setPanelOverlay(false); } }); } async function refreshAssetsForCurrentContext() { if (!isMemberSignedIn) return; const assetsPanel = document.querySelector('[data-editor-panel="assets"]'); if (!assetsPanel) return; bindAssetsPanelFileDrop(assetsPanel); const useOverlay = !suppressAssetsOverlayOnce && activeEditorTab === 'assets'; suppressAssetsOverlayOnce = false; if (useOverlay) setPanelOverlay(true, 'Loading assets'); try { const { jwt, payload, contextKey } = await getEditorAuthContext(); activeScopeId = contextKey; const paths = await listEditorAssets(payload, jwt); if (!paths.length) { assetsPanel.innerHTML = `
Assets

Click New or Upload to get started.

No files yet
`; const emptyState = assetsPanel.querySelector('.editor-assets-empty'); emptyState?.addEventListener('click', (e) => { if (e.target?.closest('.editor-assets-actions') || e.target?.closest('.editor-assets-search-wrap')) return; newTypeBackdrop.hidden = false; syncBodyScrollLock(); }); return; } const cards = paths.map((path, index) => { const safe = String(path || ''); const kind = assetKindFromPath(safe); const label = assetTypeLabel(safe); const cleanName = toCleanAssetLabel(safe); const safePath = safe.replace(/"/g, '"'); const checkId = `editor-asset-check-${index}`; return `
`; }).join(''); assetsPanel.innerHTML = `
Assets
${cards}
`; const assetsSearchInput = assetsPanel.querySelector('#editor-assets-search'); const selectedAssets = new Set(); const selectableCards = [...assetsPanel.querySelectorAll('[data-selectable-asset="true"]')]; const actionsWrap = assetsPanel.querySelector('.editor-assets-actions'); const duplicateBtn = actionsWrap?.querySelector('.editor-assets-action-btn--duplicate'); const downloadBtn = actionsWrap?.querySelector('.editor-assets-action-btn--download'); const deleteBtn = actionsWrap?.querySelector('.editor-assets-action-btn--delete'); const updateActionState = () => { const active = selectedAssets.size > 0; actionsWrap?.classList.toggle('is-active', active); if (duplicateBtn) duplicateBtn.disabled = !active; if (downloadBtn) downloadBtn.disabled = !active; if (deleteBtn) deleteBtn.disabled = !active; }; const setCardSelection = (card, selected) => { if (!card) return; const key = card.getAttribute('data-asset-path') || ''; if (!key) return; if (selected) selectedAssets.add(key); else selectedAssets.delete(key); const check = card.querySelector('.editor-asset-check'); if (check) check.checked = !!selected; card.classList.toggle('is-selected', !!selected); card.setAttribute('aria-selected', selected ? 'true' : 'false'); updateActionState(); }; const clearSelection = () => { selectableCards.forEach((card) => setCardSelection(card, false)); }; const getVisibleSelectableCards = () => selectableCards.filter((card) => !card.hidden); const filterVisibleAssets = () => { const term = (assetsSearchInput?.value || '').trim().toLowerCase(); const assetCards = [...assetsPanel.querySelectorAll('[data-selectable-asset="true"]')]; assetCards.forEach((card) => { const fileName = (card.querySelector('strong')?.textContent || '').toLowerCase(); card.hidden = !!term && !fileName.includes(term); }); }; assetsSearchInput?.addEventListener('input', filterVisibleAssets); assetsPanel.querySelectorAll('.editor-asset-check').forEach((check) => { check.addEventListener('click', (e) => { e.stopPropagation(); const card = check.closest('[data-selectable-asset="true"]'); setCardSelection(card, !!check.checked); }); }); assetsPanel.querySelectorAll('.editor-asset-open[data-asset-path]').forEach((btn) => { btn.addEventListener('click', async () => { const path = btn.getAttribute('data-asset-path') || ''; const ext = getFileExtension(path); if (STRUCTURE_EXTENSIONS.has(ext)) return showToast?.('error', 'Structure editor coming soon'); if (LIGAND_EXTENSIONS.has(ext)) return showToast?.('error', 'Ligand editor coming soon'); if (!SEQUENCE_EXTENSIONS.has(ext)) return showToast?.('error', 'Unsupported asset type'); const shouldShowOverlay = activeEditorTab === 'assets'; if (shouldShowOverlay) setPanelOverlay(true, `Loading ${toCleanAssetLabel(path)}`); try { const { jwt, payload } = await getEditorAuthContext(); const data = await fetchEditorAsset(payload, path, jwt); const content = String(data?.editor_content?.file_content || data?.file_content || ''); let restored = false; const restoredState = extractPersistedSequenceState(content); if (restoredState) { applySequenceEditorState(restoredState); restored = true; } if (!restored) applySequenceEditorState(parseLegacyFasta(content)); currentAssetPath = path; hasUnsavedChanges = false; selectEditorTab('sequence'); requestAnimationFrame(() => requestAnimationFrame(() => { syncViewToggleButtons?.(); renderSequenceViewer?.(); })); } catch (err) { showToast?.('error', err?.message || 'Failed to open asset'); } finally { if (shouldShowOverlay) setPanelOverlay(false); } }); }); let marqueeState = null; let emptyCanvasPointerState = null; const hasAssets = () => Boolean(assetsPanel.querySelector('[data-selectable-asset="true"]')); assetsPanel.addEventListener('pointerdown', (e) => { if (e.button !== 0) return; if (e.target.closest('button, a, input, textarea, select, [role="button"]')) return; if (!hasAssets()) { emptyCanvasPointerState = { startX: e.clientX, startY: e.clientY, dragged: false }; return; } const panelRect = assetsPanel.getBoundingClientRect(); const marquee = document.createElement('div'); marquee.className = 'editor-assets-marquee'; assetsPanel.appendChild(marquee); marqueeState = { startX: e.clientX, startY: e.clientY, panelRect, marquee }; clearSelection(); document.body.classList.add('editor-assets-marquee-active'); assetsPanel.setPointerCapture?.(e.pointerId); }); assetsPanel.addEventListener('pointermove', (e) => { if (!hasAssets()) { if (!emptyCanvasPointerState) return; const dx = Math.abs(e.clientX - emptyCanvasPointerState.startX); const dy = Math.abs(e.clientY - emptyCanvasPointerState.startY); if (dx > 4 || dy > 4) emptyCanvasPointerState.dragged = true; return; } if (!marqueeState) return; const x1 = Math.min(marqueeState.startX, e.clientX); const y1 = Math.min(marqueeState.startY, e.clientY); const x2 = Math.max(marqueeState.startX, e.clientX); const y2 = Math.max(marqueeState.startY, e.clientY); marqueeState.marquee.style.left = `${x1 - marqueeState.panelRect.left}px`; marqueeState.marquee.style.top = `${y1 - marqueeState.panelRect.top}px`; marqueeState.marquee.style.width = `${x2 - x1}px`; marqueeState.marquee.style.height = `${y2 - y1}px`; getVisibleSelectableCards().forEach((card) => { const r = card.getBoundingClientRect(); const hit = !(r.right < x1 || r.left > x2 || r.bottom < y1 || r.top > y2); setCardSelection(card, hit); }); }); const endMarquee = (e) => { if (!hasAssets()) { if (emptyCanvasPointerState && !emptyCanvasPointerState.dragged) { newTypeBackdrop.hidden = false; syncBodyScrollLock(); } emptyCanvasPointerState = null; return; } if (!marqueeState) return; assetsPanel.releasePointerCapture?.(e.pointerId); marqueeState.marquee.remove(); marqueeState = null; document.body.classList.remove('editor-assets-marquee-active'); }; assetsPanel.addEventListener('pointerup', endMarquee); assetsPanel.addEventListener('pointercancel', () => { emptyCanvasPointerState = null; endMarquee({}); }); duplicateBtn?.addEventListener('click', async () => { const targets = Array.from(selectedAssets); if (!targets.length) return; let shouldHideOverlay = false; try { const existing = [...paths]; let preferredNames = []; try { preferredNames = await requestDuplicateNames(targets, existing); } catch (err) { if (String(err?.message || '').toLowerCase() === 'cancelled') return; throw err; } setPanelOverlay(true, targets.length > 1 ? 'Duplicating files' : `Duplicating ${toCleanAssetLabel(targets[0])}`); shouldHideOverlay = true; const { jwt, payload } = await getEditorAuthContext(); for (let i = 0; i < targets.length; i += 1) { const targetPath = targets[i]; const preferredName = preferredNames[i] || ''; const created = await duplicateEditorAsset(payload, targetPath, jwt, existing, preferredName); existing.push(created.path); } showToast?.('success', targets.length > 1 ? 'Assets duplicated' : 'Asset duplicated'); await refreshAssetsForCurrentContext(); } catch (err) { showToast?.('error', err?.message || 'Failed to duplicate asset'); } finally { if (shouldHideOverlay) setPanelOverlay(false); } }); downloadBtn?.addEventListener('click', async () => { const targets = Array.from(selectedAssets); if (!targets.length) return; setPanelOverlay(true, targets.length > 1 ? 'Downloading files' : `Downloading ${toCleanAssetLabel(targets[0])}`); try { const { jwt, payload } = await getEditorAuthContext(); for (const path of targets) { const data = await downloadEditorAsset(payload, path, jwt); triggerEditorAssetDownload(data); } } catch (err) { showToast?.('error', err?.message || 'Failed to download asset'); } finally { setPanelOverlay(false); } }); deleteBtn?.addEventListener('click', () => { const targets = Array.from(selectedAssets); if (!targets.length) return; openDeleteConfirm(targets); }); updateActionState(); } catch (err) { assetsPanel.innerHTML = '

Failed to load assets.

'; showToast?.('error', err?.message || 'Failed to list assets'); } finally { if (useOverlay) setPanelOverlay(false); } } function getActiveSequenceFileName() { if (currentAssetPath) return currentAssetPath; const baseName = stripTrailingExtension((nameInput?.value || 'sequence').trim() || 'sequence'); return `${baseName}.fasta`; } async function saveCurrentSequenceToBackend(opts = {}) { const { member, jwt, payload } = await getEditorAuthContext(); const targetPayload = opts.payload || payload; const fileName = getActiveSequenceFileName(); const ext = getFileExtension(fileName) || 'fasta'; const body = { editor_content: { file_name: fileName, file_extension: ext.toLowerCase(), file_content: serializeSequenceEditorState(stripTrailingExtension(fileName)), ui_state: getSequencePersistenceState(), } }; if (currentAssetPath) body.file_name = currentAssetPath; const saved = await saveEditorAsset(targetPayload, body, jwt); currentAssetPath = currentAssetPath || fileName; hasUnsavedChanges = false; return saved; } async function handleContextSwitch(ctxValue) { const previousCtx = activeScopeId; try { const member = await getCurrentMemberData(); const oldPayload = window.ViciContext?.payloadFor?.(member.id) || { user_id: member.id }; const hasOpen = Boolean(String(seqRaw || '').trim().length || currentAssetPath); if (hasOpen && hasUnsavedChanges) { try { await withEditorSync('Syncing', async () => saveCurrentSequenceToBackend({ payload: oldPayload })); } catch (_) {} } } catch (_) {} clearSequenceState(); currentAssetPath = null; hasUnsavedChanges = false; await withEditorSync('Switching context', async () => refreshAssetsForCurrentContext()); } function clearSequenceState() { seqRaw = ''; residueScoreMap = []; highlights.length = 0; proteinCodonOverrides.clear(); selectionRange = null; selectionTrack = null; selectionRanges = []; if (nameInput) nameInput.value = ''; if (lenEl) lenEl.textContent = '–'; if (posEl) posEl.textContent = '–'; applyTypePill('unknown'); applyDefaultViewToggles('unknown'); updateSeqViews(); updateSelectionToolbar(); updateEditButtonState(); hasUnsavedChanges = false; } async function saveCurrentFileBestEffort() { try { await withEditorSync('Syncing', async () => saveCurrentSequenceToBackend()); await withEditorSync('Syncing', async () => refreshAssetsForCurrentContext()); } catch (err) { throw err; } } function applyModalTextToViewer(text, fileName = '') { const persistedState = extractPersistedSequenceState(text); if (persistedState) { applySequenceEditorState(persistedState); if (fileName && nameInput && !nameInput.value) nameInput.value = stripTrailingExtension(fileName); if (!isApplyingPersistedState) { markEditorDirty(); currentAssetPath = null; } updateEditButtonState(); return true; } const raw = String(text || '').trim(); if (!raw) { clearSequenceState(); return true; } if (/^\s*(ATOM|HETATM)\b/m.test(raw)) { const entries = parseStructureFile(raw); if (entries && entries.length) { applyStructuredEntries(entries); return true; } } const grouped = sanitizeAndGroup(raw); seqRaw = grouped.text; proteinCodonOverrides.clear(); const processed = processRaw(seqRaw); ensureScoreMapLength(processed.length); applyDefaultViewToggles(detectSeqType(processed.seq)); updateSeqViews(); lastState = seqRaw; if (!isApplyingPersistedState) { markEditorDirty(); currentAssetPath = null; } updateEditButtonState(); return true; } function attachCanvasFileDrop() { if (!seqViewer) return; if (seqViewer.dataset.dropBound === '1') return; seqViewer.dataset.dropBound = '1'; const prevent = (e) => { e.preventDefault(); e.stopPropagation(); }; ['dragenter', 'dragover'].forEach((evt) => { seqViewer.addEventListener(evt, (e) => { prevent(e); seqViewer.classList.add('is-dragover'); }, { passive: false }); }); ['dragleave', 'drop'].forEach((evt) => { seqViewer.addEventListener(evt, (e) => { prevent(e); seqViewer.classList.remove('is-dragover'); }, { passive: false }); }); seqViewer.addEventListener('drop', async (e) => { prevent(e); if (e.__viciSeqDropHandled) return; e.__viciSeqDropHandled = true; const f = e.dataTransfer?.files?.[0]; if (!f) return; const text = await readFileAsText(f); const has = (String(seqRaw || '').trim().length > 0) || viewerHasContent(); if (has) { try { await saveCurrentFileBestEffort(); showToast?.('success', 'Saved'); } catch (_) {} } clearSelection(); hardClearSearchUI(); await nextFrame(); applyModalTextToViewer(text, f.name); updateEditButtonState?.(); showToast?.('success', 'Loaded'); }, { passive: false }); } attachCanvasFileDrop(); (async () => { isMemberSignedIn = await detectMemberSignIn(); const hashTarget = String(window.location.hash || '').replace(/^#/, '').toLowerCase(); const fallbackTab = isMemberSignedIn ? 'assets' : 'sequence'; const desiredTab = ['assets', 'sequence', 'structure', 'ligand'].includes(hashTarget) ? hashTarget : fallbackTab; selectEditorTab(desiredTab === 'assets' && !isMemberSignedIn ? 'sequence' : desiredTab); updateHeaderActionsForTab(); })(); ensureAtLeastOneSeqBlock(); refreshSeqBlockMoveButtons(); syncHiddenModalText(); function readFileAsText(file) { return new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(String(r.result || '')); r.onerror = () => reject(r.error || new Error('Failed to read file')); r.readAsText(file); }); } async function handleSeqModalFile(file) { if (!file) return; const text = await readFileAsText(file); const persisted = extractPersistedSequenceState(text); const modalText = persisted ? String(persisted.seqRaw || '') : text; if (seqModalInput) seqModalInput.value = modalText; await nextFrame(); populateSeqBlocksFromRaw(modalText); syncHiddenModalText(); showToast?.('success', 'Loaded into modal'); updateEditButtonState(); } function hasSequenceWorkingState() { const hasCanvasContent = (String(seqRaw || '').trim().length > 0) || viewerHasContent() || !!currentAssetPath; return !!hasCanvasContent; } function updateHeaderActionsForTab() { if (!newFileBtn || !editFileBtn) return; if (activeEditorTab === 'assets') { newFileBtn.innerHTML = 'New'; editFileBtn.innerHTML = `${UPLOAD_SVG}Upload`; return; } const has = hasSequenceWorkingState(); if (activeEditorTab === 'sequence' && has) { newFileBtn.innerHTML = `${EDIT_PENCIL_SVG}Edit`; editFileBtn.innerHTML = ` Save`; return; } newFileBtn.innerHTML = 'New'; editFileBtn.innerHTML = `${UPLOAD_SVG}Upload`; } editFileBtn?.addEventListener('click', async () => { if (!isMemberSignedIn) { redirectToSignUp(); return; } if (activeEditorTab === 'sequence' && hasSequenceWorkingState()) { try { await saveCurrentFileBestEffort(); } catch (err) { showToast?.('error', err?.message || 'Failed to save before closing'); return; } clearSequenceState(); currentAssetPath = null; hasUnsavedChanges = false; updateHeaderActionsForTab(); showToast?.('success', 'Saved and closed'); return; } if (activeEditorTab === 'assets') { newTypeBackdrop.hidden = false; syncBodyScrollLock(); return; } if (activeEditorTab !== 'sequence') { showToast?.('info', `${activeEditorTab[0].toUpperCase()}${activeEditorTab.slice(1)} tools coming soon`); return; } openSeqModal(seqRaw || ''); }); seqModalClose?.addEventListener('click', closeSeqModal); seqModalBackdrop?.addEventListener('click', (e) => { if (e.target === seqModalBackdrop) closeSeqModal(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && seqModalBackdrop && !seqModalBackdrop.hidden) { closeSeqModal(); } }); seqModalClear?.addEventListener('click', () => { populateSeqBlocksFromRaw(''); syncHiddenModalText(); }); seqModalApply?.addEventListener('click', async () => { syncHiddenModalText(); await nextFrame(); applyModalTextToViewer(seqModalInput?.value || ''); closeSeqModal(); }); seqModalFileInput?.addEventListener('change', async () => { const f = seqModalFileInput.files && seqModalFileInput.files[0]; if (!f) return; await handleSeqModalFile(f); seqModalFileInput.value = ''; }); if (seqModalDropzone) { const prevent = (e) => { e.preventDefault(); e.stopPropagation(); }; ['dragenter', 'dragover'].forEach((evt) => { seqModalDropzone.addEventListener(evt, (e) => { prevent(e); seqModalDropzone.classList.add('is-dragover'); }); }); ['dragleave', 'drop'].forEach((evt) => { seqModalDropzone.addEventListener(evt, (e) => { prevent(e); seqModalDropzone.classList.remove('is-dragover'); }); }); seqModalDropzone.addEventListener('drop', async (e) => { const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; if (f) await handleSeqModalFile(f); }); } function syncActiveNucType() { if (showRNA) { activeNucType = 'rna'; lastNucType = 'rna'; return; } if (showDNA) { activeNucType = 'dna'; lastNucType = 'dna'; return; } activeNucType = null; } function getPreferredNucType(baseType) { return activeNucType || lastNucType || baseType; } const palette = { aqua: '#00f0ff', bolt: '#1e6cff', ultraviolet: '#a158ff', pulse: '#ff4df8', sunrise: '#ffca2b', ember: '#ff6b3d', neon: '#1fff9f', rose: '#ff3b81' }; const swatchKeys = Object.keys(palette); const seqPalette = { blue: '#4fc4ff', green: '#1ed573', red: '#ff6b6b', purple: '#b388ff' }; const ntSchemes = { classic: { A:'#4fc4ff', C:'#1fff9f', G:'#ffca2b', T:'#ff3b81', U:'#ff3b81', N:'#9fb7d6', R:'#9fb7d6', Y:'#9fb7d6', S:'#9fb7d6', W:'#9fb7d6', K:'#9fb7d6', M:'#9fb7d6', B:'#9fb7d6', D:'#9fb7d6', H:'#9fb7d6', V:'#9fb7d6', '-':'#9fb7d6' }, muted: { A:'#9fb7d6', C:'#9fb7d6', G:'#9fb7d6', T:'#9fb7d6', U:'#9fb7d6', N:'#9fb7d6', R:'#9fb7d6', Y:'#9fb7d6', S:'#9fb7d6', W:'#9fb7d6', K:'#9fb7d6', M:'#9fb7d6', B:'#9fb7d6', D:'#9fb7d6', H:'#9fb7d6', V:'#9fb7d6', '-':'#9fb7d6' }, neon: { A:'#00f0ff', C:'#1fff9f', G:'#ff4df8', T:'#ffca2b', U:'#ffca2b', N:'#9fb7d6', R:'#9fb7d6', Y:'#9fb7d6', S:'#9fb7d6', W:'#9fb7d6', K:'#9fb7d6', M:'#9fb7d6', B:'#9fb7d6', D:'#9fb7d6', H:'#9fb7d6', V:'#9fb7d6', '-':'#9fb7d6' }, pastel: { A:'#8bd3ff', C:'#a7f3d0', G:'#fde68a', T:'#f9a8d4', U:'#f9a8d4', N:'#cbd5e1', R:'#cbd5e1', Y:'#cbd5e1', S:'#cbd5e1', W:'#cbd5e1', K:'#cbd5e1', M:'#cbd5e1', B:'#cbd5e1', D:'#cbd5e1', H:'#cbd5e1', V:'#cbd5e1', '-':'#cbd5e1' }, highcontrast:{ A:'#00b86b', C:'#2ba3f9', G:'#ff4b5c', T:'#ffca2b', U:'#ffca2b', N:'#9fb7d6', R:'#9fb7d6', Y:'#9fb7d6', S:'#9fb7d6', W:'#9fb7d6', K:'#9fb7d6', M:'#9fb7d6', B:'#9fb7d6', D:'#9fb7d6', H:'#9fb7d6', V:'#9fb7d6', '-':'#9fb7d6' }, purinepyr: { A:'#00b86b', G:'#00b86b', C:'#2ba3f9', T:'#2ba3f9', U:'#2ba3f9', N:'#9fb7d6', R:'#00b86b', Y:'#2ba3f9', S:'#9fb7d6', W:'#9fb7d6', K:'#9fb7d6', M:'#9fb7d6', B:'#9fb7d6', D:'#9fb7d6', H:'#9fb7d6', V:'#9fb7d6', '-':'#9fb7d6' }, gc_at: { G:'#00b86b', C:'#00b86b', A:'#ffca2b', T:'#ffca2b', U:'#ffca2b', N:'#9fb7d6', R:'#9fb7d6', Y:'#9fb7d6', S:'#00b86b', W:'#ffca2b', K:'#9fb7d6', M:'#9fb7d6', B:'#9fb7d6', D:'#9fb7d6', H:'#9fb7d6', V:'#9fb7d6', '-':'#9fb7d6' } }; const notify = (type, message) => { if (typeof showToast === 'function') showToast(type, message); }; const isLocked = () => lockBtn?.classList.contains('is-locked'); function ensureScoreMapLength(len) { if (residueScoreMap.length > len) residueScoreMap = residueScoreMap.slice(0, len); else if (residueScoreMap.length < len) residueScoreMap = residueScoreMap.concat(new Array(len - residueScoreMap.length).fill(null)); } function scoreToColor(value) { if (value === null || Number.isNaN(value)) return null; const clamped = Math.max(0, Math.min(100, value)); if (clamped >= 80) return seqPalette.red; if (clamped >= 60) return seqPalette.blue; if (clamped <= 20) return seqPalette.green; return null; } function processRaw(raw) { let seq = ''; let inHeaderLine = false; for (let i = 0; i < raw.length; i++) { const ch = raw[i]; if (ch === '\n' || ch === '\r') { inHeaderLine = false; continue; } if (ch === '>' && (i === 0 || raw[i - 1] === '\n' || raw[i - 1] === '\r')) { inHeaderLine = true; continue; } if (inHeaderLine) continue; if (/[A-Za-z]/.test(ch)) seq += ch.toUpperCase(); } return { seq, length: seq.length }; } function sanitizeAndGroup(raw) { if (!raw) return { text: '' }; const norm = raw.replace(/\r\n?/g, '\n'); const lines = norm.split('\n'); const entries = []; let currentHeader = null; let currentSeq = ''; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; if (line.startsWith('>')) { if (currentHeader !== null || currentSeq) { entries.push({ header: currentHeader, seq: currentSeq }); currentSeq = ''; } currentHeader = line; } else { const seqPart = line.replace(/[^A-Za-z]/g, '').toUpperCase(); if (seqPart) currentSeq += seqPart; } } if (currentHeader !== null || currentSeq) entries.push({ header: currentHeader, seq: currentSeq }); if (!entries.length) return { text: '' }; const parts = []; for (let i = 0; i < entries.length; i++) { const entry = entries[i]; if (entry.header) parts.push(entry.header); if (entry.seq) parts.push(entry.seq); if (i < entries.length - 1) parts.push(''); } return { text: parts.join('\n') }; } function getPrimarySelectableTrack() { if (selectionTrack && seqViewer.querySelector(`[data-track="${selectionTrack}"][data-pos-start]`)) { return selectionTrack; } const first = seqViewer.querySelector('[data-track][data-pos-start]'); return first?.getAttribute('data-track') || null; } function getVisibleSearchTracks(){ const tracks = new Set(); seqViewer.querySelectorAll('[data-track]').forEach(el => { const t = el.getAttribute('data-track'); if (t) tracks.add(t); }); return Array.from(tracks); } function getSearchTrackOrder(){ const primary = getPrimarySelectableTrack(); const visible = getVisibleSearchTracks(); if (!primary) return visible; return [primary, ...visible.filter(t => t !== primary)]; } function parseUserRanges(input, maxLen, { allowPartial = false } = {}) { const s = String(input || '') .replace(/[–—]/g, '-') .replace(/\s+/g, '') .trim(); if (!s) return null; const nums = s.match(/\d+/g); if (!nums || !nums.length) return null; const n1 = parseInt(nums[0], 10); const n2 = nums.length >= 2 ? parseInt(nums[1], 10) : NaN; if (!Number.isFinite(n1)) return null; const clampTo = (v) => { if (!Number.isFinite(v)) return null; if (maxLen && Number.isFinite(maxLen)) return Math.max(1, Math.min(maxLen, v)); return Math.max(1, v); }; const hasDash = s.includes('-'); if (hasDash && allowPartial && (!Number.isFinite(n2))) { const a = clampTo(n1); if (!a) return null; return { start: a, end: a }; } if (!Number.isFinite(n2)) { const a = clampTo(n1); if (!a) return null; return { start: a, end: a }; } const a = clampTo(n1); const b = clampTo(n2); if (!a || !b) return null; return { start: Math.min(a, b), end: Math.max(a, b) }; } function parseUserRangesMulti(input, maxLen, { allowPartial = false } = {}) { const raw = String(input || '') .replace(/[–—]/g, '-') .replace(/\s+/g, ' ') .trim(); if (!raw) return null; const toks = raw.split(/[,\n;]+/).map(t => t.trim()).filter(Boolean); if (!toks.length) return null; const parseOne = (typeof parseUserRanges === 'function') ? parseUserRanges : function (tok, maxLen2, { allowPartial: allowPartial2 = false } = {}) { const s = String(tok || '') .replace(/[–—]/g, '-') .replace(/\s+/g, '') .trim(); if (!s) return null; const nums = s.match(/\d+/g); if (!nums || !nums.length) return null; const n1 = parseInt(nums[0], 10); const n2 = nums.length >= 2 ? parseInt(nums[1], 10) : NaN; if (!Number.isFinite(n1)) return null; const clampTo = (v) => { if (!Number.isFinite(v)) return null; if (maxLen2 && Number.isFinite(maxLen2)) return Math.max(1, Math.min(maxLen2, v)); return Math.max(1, v); }; const hasDash = s.includes('-'); if (hasDash && allowPartial2 && (!Number.isFinite(n2))) { const a = clampTo(n1); if (!a) return null; return { start: a, end: a }; } if (!Number.isFinite(n2)) { const a = clampTo(n1); if (!a) return null; return { start: a, end: a }; } const a = clampTo(n1); const b = clampTo(n2); if (!a || !b) return null; return { start: Math.min(a, b), end: Math.max(a, b) }; }; const out = []; for (const tok of toks) { const r = parseOne(tok, maxLen, { allowPartial }); if (r) out.push(r); } return out.length ? out : null; } function getRangeInputContext() { const track = selectionTrack || getPrimarySelectableTrack() || 'protein'; const anchor = (selectionRange ? findCellForTrackPos(track, selectionRange.start) : null) || seqViewer.querySelector(`[data-track="${track}"][data-pos-start]`); const ctx = getContextFromElement(anchor); const chainStart = Number(ctx.chainStart) || 1; const chainLen = Number(ctx.chainLen) || processRaw(seqRaw).length; let maxInputLen = processRaw(seqRaw).length; if (track === 'translation') { maxInputLen = Math.max(1, Math.floor(chainLen / 3)); } else if (track === 'triplet' || track === 'triplet-rev') { maxInputLen = Math.max(1, chainLen * 3); } const toSelectionRange = (r) => { if (!r) return null; if (track === 'translation') { const aaStart = r.start; const aaEnd = r.end; const startNt = chainStart + ((aaStart - 1) * 3); const endNt = chainStart + (aaEnd * 3) - 1; return { start: startNt, end: endNt }; } if (track === 'triplet' || track === 'triplet-rev') { const ntStart = r.start; const ntEnd = r.end; const aaStart = Math.floor((ntStart - 1) / 3) + 1; const aaEnd = Math.floor((ntEnd - 1) / 3) + 1; return { start: chainStart + aaStart - 1, end: chainStart + aaEnd - 1 }; } return { start: r.start, end: r.end }; }; return { track, maxInputLen, toSelectionRange }; } function getAutoScrollElement() { if (seqViewer && (seqViewer.scrollHeight > seqViewer.clientHeight + 2)) return seqViewer; return document.scrollingElement || document.documentElement; } function tickDragAutoScroll() { if (dragAnchorStart === null || !dragPointer) { dragRaf = null; return; } const scroller = getAutoScrollElement(); const rect = scroller === seqViewer ? scroller.getBoundingClientRect() : { top: 0, bottom: window.innerHeight, left: 0, right: window.innerWidth }; const viewTop = Math.max(rect.top, 0); const viewBottom = Math.min(rect.bottom, window.innerHeight); const viewH = Math.max(1, viewBottom - viewTop); const edge = Math.max(48, Math.min(110, viewH * 0.18)); let vy = 0; if (dragPointer.y < viewTop + edge) { const d = (viewTop + edge - dragPointer.y); vy = -Math.min(28, Math.max(6, d * 0.22)); } else if (dragPointer.y > viewBottom - edge) { const d = (dragPointer.y - (viewBottom - edge)); vy = Math.min(28, Math.max(6, d * 0.22)); } if (vy !== 0) { const prevTop = (scroller === seqViewer) ? scroller.scrollTop : (window.scrollY || 0); if (scroller === seqViewer) { scroller.scrollTop += vy; } else { window.scrollBy(0, vy); } const newTop = (scroller === seqViewer) ? scroller.scrollTop : (window.scrollY || 0); if (newTop !== prevTop) { const probeY = vy > 0 ? (viewBottom - 3) : (viewTop + 3); const r = getRangeFromPoint(dragPointer.x, probeY, selectionTrack || null); if (r && r.track && (!selectionTrack || r.track === selectionTrack)) { setSelection( dragAnchorStart, r.end, dragAppending ? { append: true, appendIndex: dragAppendIndex } : {} ); } } } dragRaf = requestAnimationFrame(tickDragAutoScroll); } function startDragAutoScroll() { if (dragRaf) cancelAnimationFrame(dragRaf); dragRaf = requestAnimationFrame(tickDragAutoScroll); } function stopDragAutoScroll() { if (dragRaf) cancelAnimationFrame(dragRaf); dragRaf = null; dragPointer = null; } function rangesOverlap(a, b) { return !(a.end < b.start || a.start > b.end); } function selectionContainsRange(track, r) { if (!r || !track) return false; if (selectionTrack !== track) return false; return getActiveRanges().some(sel => rangesOverlap(sel, r)); } function blurEditorFieldsForSelection() { if (document.activeElement === posEl) posEl.blur(); if (document.activeElement === searchInput) searchInput.blur(); } function subtractRangeFromRanges(ranges, sub) { const out = []; for (const r of ranges) { if (!rangesOverlap(r, sub)) { out.push(r); continue; } if (sub.start <= r.start && sub.end >= r.end) continue; if (sub.start > r.start) { out.push({ start: r.start, end: Math.min(r.end, sub.start - 1) }); } if (sub.end < r.end) { out.push({ start: Math.max(r.start, sub.end + 1), end: r.end }); } } return mergeRanges(out); } function parseEntriesFromRaw(raw) { const norm = String(raw || '').replace(/\r\n?/g, '\n'); const hasHeader = /^>/m.test(norm); if (!hasHeader) { const seqClean = norm.replace(/[^A-Za-z]/g, '').toUpperCase(); if (!seqClean) return []; return [{ header: null, seq: seqClean }]; } const lines = norm.split('\n'); const entries = []; let currentHeader = null; let currentSeqLines = []; const pushIfNonEmpty = () => { const seqClean = currentSeqLines.join('').replace(/[^A-Za-z]/g, '').toUpperCase(); if (seqClean) entries.push({ header: currentHeader, seq: seqClean }); }; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.startsWith('>')) { if (currentHeader !== null) pushIfNonEmpty(); currentHeader = line; currentSeqLines = []; } else { currentSeqLines.push(line); } } if (currentHeader !== null) pushIfNonEmpty(); return entries; } function detectSeqType(seq) { if (!seq.length) return 'unknown'; const dnaLike = /^[ACGTNRYKMSWBDHV]+$/i; const rnaLike = /^[ACGUNRYKMSWBDHV]+$/i; const proteinLike = /^[ACDEFGHIKLMNPQRSTVWY]+$/i; if (dnaLike.test(seq) && !/U/i.test(seq)) return 'dna'; if (rnaLike.test(seq) && /U/i.test(seq)) return 'rna'; if (proteinLike.test(seq)) return 'protein'; return 'mixed'; } function normalizeSearchQuery(q) { return String(q || '').replace(/[^A-Za-z]/g, '').toUpperCase(); } function isLikelyNucQuery(q) { return /^[ACGTUNRYKMSWBDHV]+$/i.test(String(q || '')); } function parseSearchQuery(raw) { const up = String(raw || '').toUpperCase(); if (/\.{3,}/.test(up)) { const parts = up .split(/\.{3,}/) .map(p => p.replace(/[^A-Z]/g, '')) .filter(Boolean); if (parts.length >= 2) return { mode: 'wild', parts }; } return { mode: 'plain', q: up.replace(/[^A-Z]/g, '') }; } function lowerBound(arr, x) { let lo = 0, hi = arr.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (arr[mid] < x) lo = mid + 1; else hi = mid; } return lo; } function normFrameOffset(x) { return ((Number(x) % 3) + 3) % 3; } function mergeRangesLocal(ranges) { const cleaned = (ranges || []) .map(r => ({ start: Math.min(r.start, r.end), end: Math.max(r.start, r.end) })) .filter(r => Number.isFinite(r.start) && Number.isFinite(r.end)) .sort((a, b) => (a.start - b.start) || (a.end - b.end)); if (!cleaned.length) return []; const out = [cleaned[0]]; for (let i = 1; i < cleaned.length; i++) { const cur = cleaned[i]; const last = out[out.length - 1]; if (cur.start <= last.end + 1) last.end = Math.max(last.end, cur.end); else out.push(cur); } return out; } function getMainSelectionRanges() { if (typeof getActiveRanges === 'function') { const r = getActiveRanges(); if (Array.isArray(r) && r.length) return mergeRangesLocal(r); } if (Array.isArray(selectionRanges) && selectionRanges.length) return mergeRangesLocal(selectionRanges); if (selectionRange) return mergeRangesLocal([selectionRange]); return []; } function buildChainMeta(entries) { let chainStart = 1; const meta = []; for (let i = 0; i < entries.length; i++) { const seq = String(entries[i]?.seq || '').toUpperCase(); const len = seq.length; const start = chainStart; const end = start + len - 1; const baseType = detectSeqType(seq); meta.push({ i, start, end, len, baseType }); chainStart = end + 1; } return meta; } function normalizeAndMergeTranslationRanges(ranges) { const cleaned = (ranges || []) .map(r => ({ start: Math.min(Number(r.start), Number(r.end)), end: Math.max(Number(r.start), Number(r.end)), chainStart: Number(r.chainStart) || 1, frameOffset: normFrameOffset(r.frameOffset) })) .filter(r => Number.isFinite(r.start) && Number.isFinite(r.end) && r.start <= r.end) .sort((a, b) => (a.chainStart - b.chainStart) || (a.frameOffset - b.frameOffset) || (a.start - b.start) || (a.end - b.end) ); const out = []; for (const r of cleaned) { const last = out[out.length - 1]; if ( last && last.chainStart === r.chainStart && last.frameOffset === r.frameOffset && r.start <= last.end + 1 ) { last.end = Math.max(last.end, r.end); } else { out.push({ ...r }); } } return out; } function rebaseTranslationRanges(prevMeta, nextMeta, ranges) { if (!Array.isArray(ranges) || !ranges.length) return ranges; if (!Array.isArray(prevMeta) || !prevMeta.length) return ranges; if (!Array.isArray(nextMeta) || !nextMeta.length) return ranges; const out = []; for (const r of ranges) { if (!r) continue; let prevIndex = prevMeta.findIndex(m => Number(m.start) === Number(r.chainStart)); if (prevIndex < 0) { prevIndex = prevMeta.findIndex(m => Number(r.start) >= m.start && Number(r.start) <= m.end); } if (prevIndex < 0) continue; const prev = prevMeta[prevIndex]; const next = nextMeta[prevIndex]; if (!next) continue; if (next.baseType !== 'dna' && next.baseType !== 'rna') continue; const delta = Number(next.start) - Number(prev.start); let start = Number(r.start) + delta; let end = Number(r.end) + delta; const chainStart = Number(next.start); const chainEnd = chainStart + Number(next.len) - 1; if (!Number.isFinite(start) || !Number.isFinite(end)) continue; start = Math.max(chainStart, start); end = Math.min(chainEnd, end); if (start > end) continue; out.push({ ...r, start, end, chainStart }); } return normalizeAndMergeTranslationRanges(out); } function applyTranslationRangeOverrides(existingRanges, newRanges) { const additions = normalizeAndMergeTranslationRanges(newRanges); if (!additions.length) return normalizeAndMergeTranslationRanges(existingRanges); let updated = Array.isArray(existingRanges) ? existingRanges.map(r => ({ ...r })) : []; for (const add of additions) { const next = []; for (const prev of updated) { if (prev.chainStart !== add.chainStart) { next.push(prev); continue; } const overlaps = !(prev.end < add.start || prev.start > add.end); if (!overlaps) { next.push(prev); continue; } if (prev.start < add.start) { next.push({ ...prev, end: add.start - 1 }); } if (prev.end > add.end) { next.push({ ...prev, start: add.end + 1 }); } } next.push({ ...add }); updated = next; } return normalizeAndMergeTranslationRanges(updated); } function getChainFrameOffset(chainStartGlobal) { const fallback = normFrameOffset(translationFrameOffset); if (!Array.isArray(translationRanges) || !translationRanges.length) return fallback; const hit = translationRanges.find(r => Number(r.chainStart) === Number(chainStartGlobal)); if (!hit) return fallback; return normFrameOffset(hit.frameOffset); } function reverseStringSpan(s, a0, b0) { if (!s || a0 > b0) return s; return s.slice(0, a0) + s.slice(a0, b0 + 1).split('').reverse().join('') + s.slice(b0 + 1); } function reverseProteinOverrideCodons(entry, idx, localStart1, localEnd1) { const key = entryKey(entry, idx); const over = proteinCodonOverrides.get(key); if (!over || typeof over.seq !== 'string') return; const nuc = String(over.seq || '').toUpperCase(); const a0 = (localStart1 - 1) * 3; const b0 = (localEnd1 * 3) - 1; if (a0 < 0 || b0 >= nuc.length || a0 > b0) return; const mid = nuc.slice(a0, b0 + 1); const blocks = mid.match(/.{1,3}/g) || []; const rev = blocks.reverse().join(''); over.seq = nuc.slice(0, a0) + rev + nuc.slice(b0 + 1); proteinCodonOverrides.set(key, over); } function getSearchContexts(qNorm) { const entries = parseEntriesFromRaw(seqRaw); const ctxs = []; const qLooksNuc = isLikelyNucQuery(qNorm); let globalPos = 1; for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const baseSeq = String(entry.seq || '').toUpperCase(); if (!baseSeq) continue; const baseType = detectSeqType(baseSeq); const chainStart = globalPos; if (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown') { const derivedType = activeNucType || (showRevComp ? lastNucType : null); const wantTriplet = Boolean(activeNucType); const wantTripletRev = Boolean(showRevComp && derivedType); if (wantTriplet || wantTripletRev) { const nucType = derivedType || 'dna'; const nuc = getDerivedNucForProtein(entry, i, baseSeq, nucType); if (wantTriplet) { ctxs.push({ track: 'triplet', hay: nuc, priority: qLooksNuc ? 0 : 4, mapToRange: (idx0, qLen) => { const aaStart = Math.floor(idx0 / 3) + 1; const aaEnd = Math.floor((idx0 + qLen - 1) / 3) + 1; return { start: chainStart + aaStart - 1, end: chainStart + aaEnd - 1 }; } }); } if (wantTripletRev) { const rev = complementSeq(nuc, nucType); ctxs.push({ track: 'triplet-rev', hay: rev, priority: qLooksNuc ? 1 : 5, mapToRange: (idx0, qLen) => { const aaStart = Math.floor(idx0 / 3) + 1; const aaEnd = Math.floor((idx0 + qLen - 1) / 3) + 1; return { start: chainStart + aaStart - 1, end: chainStart + aaEnd - 1 }; } }); } } if (showProtein) { ctxs.push({ track: 'protein', hay: baseSeq, priority: qLooksNuc ? 6 : 0, mapToRange: (idx0, qLen) => ({ start: chainStart + idx0, end: chainStart + idx0 + qLen - 1 }) }); } globalPos += baseSeq.length; continue; } if (baseType === 'dna' || baseType === 'rna') { const nucType = getPreferredNucType(baseType); const displaySeq = (nucType === 'rna') ? toRNA(baseSeq) : toDNA(baseSeq); if (showDNA || showRNA) { ctxs.push({ track: 'nt', hay: displaySeq, priority: qLooksNuc ? 0 : 4, mapToRange: (idx0, qLen) => ({ start: chainStart + idx0, end: chainStart + idx0 + qLen - 1 }) }); } if (showRevComp) { const rev = complementSeq(displaySeq, nucType); ctxs.push({ track: 'nt-rev', hay: rev, priority: qLooksNuc ? 1 : 5, mapToRange: (idx0, qLen) => ({ start: chainStart + idx0, end: chainStart + idx0 + qLen - 1 }) }); } if (showProtein) { const aa = translateNuc(displaySeq, nucType); ctxs.push({ track: 'translation', hay: aa, priority: qLooksNuc ? 7 : 0, mapToRange: (idx0, qLen) => { // translation track uses nt coordinates (each aa covers 3 nt) const startNt = chainStart + (idx0 * 3); const endNt = chainStart + ((idx0 + qLen) * 3) - 1; return { start: startNt, end: endNt }; } }); } globalPos += baseSeq.length; continue; } globalPos += baseSeq.length; } ctxs.sort((a, b) => (a.priority - b.priority)); return ctxs; } function setSearchCountVisible(visible) { if (!searchCount) return; searchCount.classList.toggle('is-visible', Boolean(visible)); } function setSearchCountValue(n) { if (!searchCount) return; searchCount.textContent = String(n); } function clearSearchHits() { if (!seqViewer) return; seqViewer.querySelectorAll('.seq-search-hit').forEach(el => el.classList.remove('seq-search-hit')); } function hardClearSearchUI() { if (searchTimer) { clearTimeout(searchTimer); searchTimer = null; } if (searchInput) searchInput.value = ''; searchAuthoredByUser = false; setSearchCountVisible(false); setSearchCountValue(0); clearSearchHits(); searchMatches = []; searchNeedRefresh = false; lastSearchQuery = ''; lastSearchIndex = -1; searchSelectionKey = ''; searchSelectionCustom = false; selectionFromSearch = false; updateMeta(); } function computeMatchStarts(hay, q) { const starts = []; if (!hay || !q) return starts; let i = 0; while (true) { const idx = hay.indexOf(q, i); if (idx === -1) break; starts.push(idx); i = idx + 1; // allow overlaps } return starts; } function applySearchHitsToTrackRanges(track, totalLen, ranges) { if (!ranges || !ranges.length) return; const mark = new Uint8Array(totalLen + 2); for (const r of ranges) { const s = Math.max(1, r.start); const e = Math.min(totalLen, r.end); for (let p = s; p <= e; p++) mark[p] = 1; } const nodes = seqViewer.querySelectorAll(`[data-track="${track}"][data-pos-start]`); nodes.forEach(node => { const ps = Number(node.getAttribute('data-pos-start')); const pe = Number(node.getAttribute('data-pos-end') || ps); let hit = false; for (let p = ps; p <= pe; p++) { if (mark[p]) { hit = true; break; } } if (hit) node.classList.add('seq-search-hit'); }); } function computeSearchSelectionKeyNow() { return JSON.stringify({ q: String(searchInput?.value || '').trim(), activeNucType, showProtein, showDNA, showRNA, showRevComp }); } function chooseBestSearchTrack() { if (!Array.isArray(searchMatches) || !searchMatches.length) return null; if (selectionTrack && searchMatches.some(m => m.track === selectionTrack)) return selectionTrack; const stats = new Map(); for (const m of searchMatches) { if (!m?.track || !Array.isArray(m.ranges)) continue; let units = 0; for (const r of m.ranges) units += (Math.abs(r.end - r.start) + 1); const cur = stats.get(m.track) || { units: 0, matches: 0 }; cur.units += units; cur.matches += 1; stats.set(m.track, cur); } let best = null; let bestUnits = -1; let bestMatches = -1; for (const [t, s] of stats.entries()) { if (s.units > bestUnits || (s.units === bestUnits && s.matches > bestMatches)) { best = t; bestUnits = s.units; bestMatches = s.matches; } } return best; } function getSearchRangesForTrack(track) { if (!track || !Array.isArray(searchMatches)) return []; const raw = searchMatches .filter(m => m.track === track) .flatMap(m => (m.ranges || []).map(r => ({ start: r.start, end: r.end }))); return mergeRanges(raw); } function refreshSearchDecorations(opts = {}) { const preserveSelection = Boolean(opts.preserveSelection); if (!searchAuthoredByUser) { clearSearchHits(); searchMatches = []; setSearchCountVisible(false); setSearchCountValue(0); updateMeta(); return; } const totalLen = processRaw(seqRaw).length; const parsed = parseSearchQuery(searchInput?.value || ''); clearSearchHits(); searchMatches = []; const hasQuery = (parsed.mode === 'plain' && parsed.q.length) || (parsed.mode === 'wild' && parsed.parts.length >= 2); if (!hasQuery || !totalLen) { setSearchCountVisible(false); if (selectionFromSearch && !preserveSelection) clearSelection(); updateMeta(); return; } const newKey = computeSearchSelectionKeyNow(); if (newKey !== searchSelectionKey) { searchSelectionKey = newKey; searchSelectionCustom = false; } setSearchCountVisible(true); const matchesByTrack = new Map(); // track -> [ cleanedRangesArray ] const segmentsByTrack = new Map(); // track -> [ {start,end}, ... ] const addMatch = (track, ranges) => { if (!track || !Array.isArray(ranges) || !ranges.length) return; const cleaned = ranges .map(r => ({ start: Math.min(r.start, r.end), end: Math.max(r.start, r.end) })) .filter(r => Number.isFinite(r.start) && Number.isFinite(r.end)); if (!cleaned.length) return; if (!matchesByTrack.has(track)) matchesByTrack.set(track, []); matchesByTrack.get(track).push(cleaned); if (!segmentsByTrack.has(track)) segmentsByTrack.set(track, []); segmentsByTrack.get(track).push(...cleaned); }; const contexts = getSearchContexts(parsed.mode === 'plain' ? parsed.q : parsed.parts[0]); for (const ctx of contexts) { const hay = String(ctx.hay || '').toUpperCase(); if (!hay.length) continue; if (parsed.mode === 'plain') { const q = parsed.q; const starts = computeMatchStarts(hay, q); for (const idx0 of starts) { const r = ctx.mapToRange(idx0, q.length); if (r) addMatch(ctx.track, [{ start: r.start, end: r.end }]); } continue; } const partA = parsed.parts[0]; const partB = parsed.parts[1]; const aStarts = computeMatchStarts(hay, partA); const bStarts = computeMatchStarts(hay, partB); if (!aStarts.length || !bStarts.length) continue; for (const a0 of aStarts) { const minB = a0 + partA.length; const j = lowerBound(bStarts, minB); if (j >= bStarts.length) continue; const b0 = bStarts[j]; const ra = ctx.mapToRange(a0, partA.length); const rb = ctx.mapToRange(b0, partB.length); if (!ra || !rb) continue; addMatch(ctx.track, [ { start: ra.start, end: ra.end }, { start: rb.start, end: rb.end } ]); } } const order = getSearchTrackOrder(); const chosenTrack = order.find(t => (matchesByTrack.get(t) || []).length) || null; searchMatches = []; if (!chosenTrack) { setSearchCountValue(0); updateMeta(); return; } const kept = matchesByTrack.get(chosenTrack) || []; searchMatches = kept.map(ranges => ({ track: chosenTrack, ranges })); setSearchCountValue(searchMatches.length); applySearchHitsToTrackRanges(chosenTrack, totalLen, segmentsByTrack.get(chosenTrack) || []); const hasManualSelection = getActiveRanges().length && !selectionFromSearch; const shouldAdopt = !hasManualSelection && !isEditingRange && !searchSelectionCustom; if (shouldAdopt) { const bestTrack = chooseBestSearchTrack(); const merged = getSearchRangesForTrack(bestTrack); if (bestTrack && merged.length) { selectionTrack = bestTrack; selectionFromSearch = true; selectionRanges = merged; selectionRange = merged[merged.length - 1] || null; lastAppliedSelectionTrack = selectionTrack || null; updateSelectionStyles(); return; } } updateMeta(); } function jumpToNextMatch() { const parsed = parseSearchQuery(searchInput?.value || ''); const hasQuery = (parsed.mode === 'plain' && parsed.q.length) || (parsed.mode === 'wild' && parsed.parts.length >= 2); if (!hasQuery) return; refreshSearchDecorations({ preserveSelection: true }); if (!searchMatches.length) { notify('error', 'No match found'); return; } const ordered = searchMatches.slice().sort((a, b) => { const aStart = Math.min(...a.ranges.map(r => r.start)); const bStart = Math.min(...b.ranges.map(r => r.start)); return (aStart - bStart) || (a.track.localeCompare(b.track)); }); let nextIdx = lastSearchIndex + 1; if (nextIdx >= ordered.length) nextIdx = 0; lastSearchIndex = nextIdx; const m = ordered[nextIdx]; selectionTrack = m.track; selectionFromSearch = true; const merged = mergeRanges(m.ranges); selectionRanges = merged; selectionRange = merged.length ? merged[merged.length - 1] : null; updateSelectionStyles(); if (merged.length) smartScrollAfterSelection(m.track, merged[0].start); } function runSearch({ fromNext = false, silent = true } = {}) { const processed = processRaw(seqRaw); const hay = processed.seq || ''; if (!hay.length) { if (!silent) notify('error', 'No sequence loaded'); return; } const q = normalizeSearchQuery(searchInput?.value || ''); if (!q.length) return; let startFrom = 0; if (fromNext && q === lastSearchQuery && selectionRange) { startFrom = Math.max(0, selectionRange.end); } else { startFrom = 0; } let idx = hay.indexOf(q, startFrom); if (idx === -1 && startFrom > 0) idx = hay.indexOf(q, 0); if (idx === -1) { if (!silent) notify('error', 'No match found'); return; } lastSearchQuery = q; lastSearchIndex = idx; const startPos = idx + 1; const endPos = startPos + q.length - 1; selectionTrack = getSearchTrackForCurrentSeq(); setSelection(startPos, endPos); smartScrollAfterSelection(selectionTrack, startPos); } if (searchInput) { searchInput.addEventListener('keydown', (e) => { if (e.key !== 'Enter') return; e.preventDefault(); const hasValue = Boolean((searchInput.value || '').trim()); if (hasValue && !searchAuthoredByUser) { searchAuthoredByUser = true; lastSearchIndex = -1; } jumpToNextMatch(); }); searchInput.addEventListener('input', () => { if (suppressSearchInput) return; searchAuthoredByUser = true; if (searchTimer) clearTimeout(searchTimer); searchTimer = setTimeout(() => { lastSearchIndex = -1; refreshSearchDecorations(); }, 120); }); } function applyTypePill(type) { const label = type === 'dna' ? 'DNA' : type === 'rna' ? 'RNA' : type === 'protein' ? 'Protein' : type === 'mixed' ? 'Mixed' : 'Type'; typePill.textContent = label; typePill.classList.remove('is-dna', 'is-rna', 'is-protein', 'is-mixed', 'is-unknown'); typePill.classList.add( type === 'dna' ? 'is-dna' : type === 'rna' ? 'is-rna' : type === 'protein' ? 'is-protein' : type === 'mixed' ? 'is-mixed' : 'is-unknown' ); } function toDNA(seq) { return String(seq || '').toUpperCase().replace(/U/g, 'T'); } function toRNA(seq) { return String(seq || '').toUpperCase().replace(/T/g, 'U'); } const iupacCompDNA = { A:'T', T:'A', U:'A', C:'G', G:'C', R:'Y', Y:'R', S:'S', W:'W', K:'M', M:'K', B:'V', V:'B', D:'H', H:'D', N:'N', '-':'-' }; const iupacCompRNA = { A:'U', U:'A', T:'A', C:'G', G:'C', R:'Y', Y:'R', S:'S', W:'W', K:'M', M:'K', B:'V', V:'B', D:'H', H:'D', N:'N', '-':'-' }; function complementBase(b, nucType) { const x = (b || '').toUpperCase(); const map = nucType === 'rna' ? iupacCompRNA : iupacCompDNA; return map[x] || 'N'; } function complementSeq(seq, nucType) { const s = String(seq || '').toUpperCase(); let out = ''; for (let i = 0; i < s.length; i++) out += complementBase(s[i], nucType); return out; } function antiCodonFromFwdCodon(fwdCodon, nucType){ const c = String(fwdCodon || '---').toUpperCase().padEnd(3,'-').slice(0,3); if (c === '---') return '---'; return complementSeq(c, nucType); } function antiIndexToFwdIndex(ntIndex){ const k = Number(ntIndex); return Number.isFinite(k) ? k : 0; } function reverseComplementSeq(seq, nucType) { return complementSeq(seq, nucType).split('').reverse().join(''); } const geneticCodeDNA = { TTT:'F',TTC:'F',TTA:'L',TTG:'L',TCT:'S',TCC:'S',TCA:'S',TCG:'S',TAT:'Y',TAC:'Y',TAA:'*',TAG:'*', TGT:'C',TGC:'C',TGA:'*',TGG:'W',CTT:'L',CTC:'L',CTA:'L',CTG:'L',CCT:'P',CCC:'P',CCA:'P',CCG:'P', CAT:'H',CAC:'H',CAA:'Q',CAG:'Q',CGT:'R',CGC:'R',CGA:'R',CGG:'R',ATT:'I',ATC:'I',ATA:'I',ATG:'M', ACT:'T',ACC:'T',ACA:'T',ACG:'T',AAT:'N',AAC:'N',AAA:'K',AAG:'K',AGT:'S',AGC:'S',AGA:'R',AGG:'R', GTT:'V',GTC:'V',GTA:'V',GTG:'V',GCT:'A',GCC:'A',GCA:'A',GCG:'A',GAT:'D',GAC:'D',GAA:'E',GAG:'E', GGT:'G',GGC:'G',GGA:'G',GGG:'G' }; function translateNuc(seq, nucType) { const dna = nucType === 'rna' ? toDNA(seq) : String(seq || '').toUpperCase(); let aa = ''; for (let i = 0; i + 2 < dna.length; i += 3) { const codon = dna.slice(i, i + 3); aa += geneticCodeDNA[codon] || 'X'; } return aa; } function addDirectionLabels(row, startText, endText, labelClass = 'seq-direction-label') { if (!row) return; row.classList.add(labelClass === 'seq-direction-label' ? 'seq-direction-row' : 'mut-direction-row'); const start = document.createElement('span'); start.className = `${labelClass} ${labelClass}--start`; start.textContent = startText; const end = document.createElement('span'); end.className = `${labelClass} ${labelClass}--end`; end.textContent = endText; row.appendChild(start); row.appendChild(end); } function bestReadingFrameOffset(seq, nucType) { const clean = String(seq || '').toUpperCase().replace(/[^ACGTU]/g, ''); if (!clean.length) return 0; let bestFrame = 0; let bestRun = -1; for (let frame = 0; frame < 3; frame++) { const aa = translateNuc(clean.slice(frame), nucType); let run = 0; let longest = 0; for (const ch of aa) { if (ch === '*') { longest = Math.max(longest, run); run = 0; } else { run += 1; } } longest = Math.max(longest, run); if (longest > bestRun) { bestRun = longest; bestFrame = frame; } } return bestFrame; } const backCodonDNA = { A:'GCT', C:'TGT', D:'GAT', E:'GAA', F:'TTT', G:'GGT', H:'CAT', I:'ATT', K:'AAA', L:'CTG', M:'ATG', N:'AAT', P:'CCT', Q:'CAA', R:'CGT', S:'TCT', T:'ACT', V:'GTT', W:'TGG', Y:'TAT', '*':'TAA', X:'NNN' }; function backTranslateProtein(prot, nucType) { const p = String(prot || '').toUpperCase(); let dna = ''; for (let i = 0; i < p.length; i++) dna += (backCodonDNA[p[i]] || 'NNN'); return nucType === 'rna' ? toRNA(dna) : dna; } function parseStructureFile(raw) { const lines = raw.split(/\r?\n/); const threeToOne = { ALA: 'A', ARG: 'R', ASN: 'N', ASP: 'D', CYS: 'C', GLN: 'Q', GLU: 'E', GLY: 'G', HIS: 'H', ILE: 'I', LEU: 'L', LYS: 'K', MET: 'M', PHE: 'F', PRO: 'P', SER: 'S', THR: 'T', TRP: 'W', TYR: 'Y', VAL: 'V', SEC: 'U', PYL: 'O' }; const chains = new Map(); let foundAtom = false; lines.forEach((line) => { const trimmed = line.trim(); if (!/^ATOM/.test(trimmed) && !/^HETATM/.test(trimmed)) return; foundAtom = true; let chainId = ''; let resName = ''; let resNum = null; let bFactor = null; if (line.length > 54) { chainId = line.slice(21, 22).trim() || 'A'; resName = line.slice(17, 20).trim(); resNum = parseInt(line.slice(22, 26).trim(), 10); const temp = parseFloat(line.slice(60, 66)); bFactor = Number.isFinite(temp) ? temp : null; } else { const parts = trimmed.split(/\s+/); resName = parts[3] || ''; chainId = parts[4] || 'A'; resNum = parseInt(parts[5], 10); const temp = parseFloat(parts[10]); bFactor = Number.isFinite(temp) ? temp : null; } const one = threeToOne[resName.toUpperCase()]; if (!one || !Number.isFinite(resNum)) return; if (!chains.has(chainId)) chains.set(chainId, { seq: [], scores: [], lastRes: null }); const chain = chains.get(chainId); if (chain.lastRes === resNum) return; chain.seq.push(one); chain.scores.push(bFactor); chain.lastRes = resNum; }); if (!foundAtom || !chains.size) return null; const entries = []; chains.forEach((value, key) => { entries.push({ header: `>Chain ${key}`, seq: value.seq.join(''), scores: value.scores }); }); return entries; } function entriesToRaw(entries) { const parts = []; for (let i = 0; i < entries.length; i++) { const e = entries[i]; if (e.header) parts.push(e.header); if (e.seq && e.seq.length) parts.push(e.seq); if (i < entries.length - 1) parts.push(''); } return parts.join('\n'); } function applyStructuredEntries(entries) { if (!entries || !entries.length) return false; residueScoreMap = []; entries.forEach((entry) => { if (Array.isArray(entry.scores)) residueScoreMap.push(...entry.scores.map((s) => (Number.isFinite(s) ? s : null))); }); const fasta = entriesToRaw(entries); const out = sanitizeAndGroup(fasta); seqRaw = out.text; const processed = processRaw(seqRaw); ensureScoreMapLength(processed.length); applyDefaultViewToggles(detectSeqType(processed.seq)); updateSeqViews(); lastState = seqRaw; return true; } function applyColorToResidue(el, residue, position) { const res = residue.toUpperCase(); el.style.removeProperty('background'); el.style.removeProperty('color'); if (proteinColorMode === 'bfactor') { const score = residueScoreMap[position - 1]; const color = scoreToColor(score); if (color) el.style.color = color; return; } if (proteinColorMode === 'type') { el.style.color = mutAaGroupColorVar(res); return; } if (proteinColorMode === 'polarity') { const polar = 'STYCNQ'; const hydrophobic = 'GAVLIPMFYW'; if (polar.includes(res)) el.style.color = seqPalette.blue; else if (hydrophobic.includes(res)) el.style.color = seqPalette.green; return; } if (proteinColorMode === 'charge') { const positive = 'HRK'; const negative = 'DE'; if (positive.includes(res)) el.style.color = seqPalette.blue; else if (negative.includes(res)) el.style.color = seqPalette.red; return; } if (proteinColorMode === 'hydrophobic') { const hydrophobic = 'AILMFWVPG'; if (hydrophobic.includes(res)) el.style.color = seqPalette.green; return; } if (proteinColorMode === 'backbone') { if (res === 'G' || res === 'P') el.style.color = seqPalette.purple; } } function mutResidueColorForHist(residue, globalPos) { const res = String(residue || 'X').toUpperCase().slice(0, 1); const fallback = 'var(--mut-text-2)'; if (proteinColorMode === 'bfactor') { const i = Number(globalPos) - 1; const score = Number.isFinite(i) && i >= 0 && i < residueScoreMap.length ? residueScoreMap[i] : null; return scoreToColor(score) || fallback; } if (proteinColorMode === 'type') { return mutAaGroupColorVar(res); } if (proteinColorMode === 'polarity') { const polar = 'STYCNQ'; const hydrophobic = 'GAVLIPMFYW'; if (polar.includes(res)) return seqPalette.blue; if (hydrophobic.includes(res)) return seqPalette.green; return fallback; } if (proteinColorMode === 'charge') { const positive = 'HRK'; const negative = 'DE'; if (positive.includes(res)) return seqPalette.blue; if (negative.includes(res)) return seqPalette.red; return fallback; } if (proteinColorMode === 'hydrophobic') { const hydrophobic = 'AILMFWVPG'; if (hydrophobic.includes(res)) return seqPalette.green; return fallback; } if (proteinColorMode === 'backbone') { if (res === 'G' || res === 'P') return seqPalette.purple; return fallback; } return fallback; } function setCellText(el, text) { el.textContent = ''; const t = document.createElement('span'); t.className = 'seq-cell-text'; t.textContent = text; el.appendChild(t); } function applyColorToNuc(el, base) { const b = (base || '').toUpperCase(); el.style.removeProperty('color'); const scheme = ntSchemes[ntScheme] || ntSchemes.classic; el.style.color = scheme[b] || scheme.N || '#9fb7d6'; } function hexToRgba(hex, alpha) { const h = String(hex || '').replace('#', '').trim(); if (h.length !== 6) return `rgba(255,255,255,${alpha})`; const r = parseInt(h.slice(0, 2), 16); const g = parseInt(h.slice(2, 4), 16); const b = parseInt(h.slice(4, 6), 16); return `rgba(${r},${g},${b},${alpha})`; } function applyHighlightsToRange(el, start, end, track) { let hit = null; for (let i = highlights.length - 1; i >= 0; i--) { const h = highlights[i]; if (h.track !== track) continue; const overlaps = !(end < h.start || start > h.end); if (overlaps) { hit = h; break; } } if (hit) { el.classList.add('seq-highlighted'); el.style.setProperty('--hl-border', hexToRgba(hit.color, 0.85)); el.style.setProperty('--hl-glow', hexToRgba(hit.color, 0.35)); el.style.setProperty('--hl-fill', hexToRgba(hit.color, 0.18)); el.style.setProperty('--hl-fill-strong', hexToRgba(hit.color, 0.30)); if (hit.label) { el.setAttribute('data-highlight-label', hit.label); el.setAttribute('title', hit.label); } else { el.removeAttribute('data-highlight-label'); el.removeAttribute('title'); } return; } el.removeAttribute('data-highlight-label'); el.removeAttribute('title'); el.classList.remove('seq-highlighted'); el.style.removeProperty('--hl-border'); el.style.removeProperty('--hl-glow'); el.style.removeProperty('--hl-fill'); el.style.removeProperty('--hl-fill-strong'); } function registerTrackCell(el, start, end, track) { if (!track || !el || !Number.isFinite(start)) return; const endPos = Number.isFinite(end) ? end : start; const map = trackCellIndex[track] || (trackCellIndex[track] = []); for (let pos = start; pos <= endPos; pos++) { map[pos] = el; } } function getNumberCssVarPx(el, name, fallback) { const v = getComputedStyle(el).getPropertyValue(name).trim(); const n = parseFloat(v); return Number.isFinite(n) ? n : fallback; } function gcd(a, b) { while (b) [a, b] = [b, a % b]; return a || 1; } function lcm(a, b) { return Math.abs(a * b) / gcd(a, b); } function getUnitMetrics(unit) { const editorBody = document.querySelector('.seq-editor-body') || seqViewer; const style = getComputedStyle(editorBody); const gap = parseFloat(style.getPropertyValue('--seq-row-gap')) || 2; const groupGap = getNumberCssVarPx(editorBody, '--seq-group-gap', 10); const cellSizeVar = unit === 'nt' ? '--seq-nt-cell-size' : '--seq-cell-size'; const cellSize = parseFloat(style.getPropertyValue(cellSizeVar)) || 32; const padL = parseFloat(getComputedStyle(seqViewer).paddingLeft) || 0; const padR = parseFloat(getComputedStyle(seqViewer).paddingRight) || 0; const usableWidth = Math.max(seqViewer.clientWidth - padL - padR - 8, cellSize); return { gap, groupGap, cellSize, usableWidth }; } function computeColumnCount(unit, alignTo = 1) { const { gap, groupGap, cellSize, usableWidth } = getUnitMetrics(unit); const mustBeMultipleOf = (() => { const a = Math.max(1, alignTo | 0); const g = groupSize > 0 ? groupSize : 1; return lcm(a, g); })(); const widthFor = (n) => { const spacers = groupSize > 0 ? Math.floor((n - 1) / groupSize) : 0; const tracks = n + spacers; return (n * cellSize) + (spacers * groupGap) + ((tracks - 1) * gap); }; let best = 0; for (let n = mustBeMultipleOf; n <= 600; n += mustBeMultipleOf) { if (widthFor(n) <= usableWidth) best = n; else break; } if (best > 0) return best; return 1; } function buildRowTemplate(nUnits) { const parts = []; for (let i = 1; i <= nUnits; i++) { parts.push('var(--seq-unit-cell-size)'); if (groupSize > 0 && i % groupSize === 0 && i !== nUnits) parts.push('var(--seq-group-gap)'); } return parts.join(' '); } function gridColumnForUnitIndex(i) { if (groupSize <= 0) return i; const spacersBefore = Math.floor((i - 1) / groupSize); return i + spacersBefore; } function deriveChainLetter(label, chainIndex) { const s = String(label || ''); const mAuth = s.match(/\bauth\s+([A-Za-z0-9])\b/i); if (mAuth && mAuth[1]) return mAuth[1].toUpperCase(); const mChain = s.match(/\bchains?\s+([A-Za-z0-9])\b/i); if (mChain && mChain[1]) return mChain[1].toUpperCase(); if (s.trim().length === 1) return s.trim().toUpperCase(); return String.fromCharCode(65 + (chainIndex % 26)); } function makeChainControls(chainIndex, chainLetter, totalChains, lenText) { const controls = document.createElement('span'); controls.className = 'seq-chain-controls'; const caret = document.createElement('span'); caret.className = 'seq-chain-caret'; const btnUp = document.createElement('button'); btnUp.type = 'button'; btnUp.setAttribute('aria-label', 'Move chain up'); btnUp.disabled = chainIndex === 0; btnUp.innerHTML = ` `; btnUp.addEventListener('click', (e) => { e.stopPropagation(); reorderChain(chainIndex, 'up'); }); const btnDown = document.createElement('button'); btnDown.type = 'button'; btnDown.setAttribute('aria-label', 'Move chain down'); btnDown.disabled = chainIndex === totalChains - 1; btnDown.innerHTML = ` `; btnDown.addEventListener('click', (e) => { e.stopPropagation(); reorderChain(chainIndex, 'down'); }); caret.appendChild(btnUp); caret.appendChild(btnDown); const id = document.createElement('span'); id.className = 'seq-chain-id'; id.textContent = chainLetter; const len = document.createElement('span'); len.className = 'seq-chain-len'; len.textContent = lenText || ''; controls.appendChild(caret); controls.appendChild(id); controls.appendChild(len); return controls; } function setRowUnitVars(rowEl, unit) { if (unit === 'nt') { rowEl.style.setProperty('--seq-unit-cell-size', 'var(--seq-nt-cell-size)'); rowEl.style.setProperty('--seq-unit-font-size', 'var(--seq-nt-font-size)'); } else { rowEl.style.setProperty('--seq-unit-cell-size', 'var(--seq-cell-size)'); rowEl.style.setProperty('--seq-unit-font-size', 'var(--seq-font-size)'); } } function padLenToGroups(n, maxCols, alignTo = 1) { if (n <= 0) return 0; let out = n; const a = Math.max(1, alignTo | 0); if (a > 1) out = Math.min(maxCols, Math.ceil(out / a) * a); if (groupSize > 0) out = Math.min(maxCols, Math.ceil(out / groupSize) * groupSize); return out; } function buildRulerRow(rowTemplate, unit, startPos, chunkLen, padLen, chainStart) { const rulerRow = document.createElement('div'); rulerRow.className = 'seq-ruler-row'; setRowUnitVars(rulerRow, unit); rulerRow.style.gridTemplateColumns = rowTemplate; const baseStart = Number.isFinite(chainStart) ? chainStart : startPos; const maxUnits = padLen; for (let i = 1; i <= maxUnits; i++) { const gridCol = gridColumnForUnitIndex(i); const pos = (startPos - baseStart) + i; const tick = document.createElement('span'); tick.className = 'seq-tick'; tick.style.gridColumn = String(gridCol); if (i <= chunkLen) { if (rulerEvery > 0 && pos % rulerEvery === 0) { tick.textContent = pos; tick.classList.add('seq-tick--label'); } } rulerRow.appendChild(tick); if (groupSize > 0 && i % groupSize === 0 && i !== maxUnits) { const spacer = document.createElement('span'); spacer.className = 'seq-tick seq-tick--spacer'; spacer.style.gridColumn = String(gridCol + 1); rulerRow.appendChild(spacer); } } return rulerRow; } function trackUnitSuffix(track) { if (!track) return ''; if (track === 'protein' || track === 'translation' || track === 'triplet' || track === 'triplet-rev') return 'aa'; if (track === 'nt' || track === 'nt-rev') return 'nt'; return ''; } function renderSequenceViewer() { seqViewer.classList.toggle('is-prot-3', proteinViewMode === 2); trackCellIndex = {}; lastSelectedCells = new Set(); const entries = parseEntriesFromRaw(seqRaw); const frag = document.createDocumentFragment(); if (!entries.length) { seqViewer.replaceChildren(); updateSelectionStyles(); return; } let globalPos = 1; entries.forEach((entry, idx) => { if (!entry.seq) return; const chainEl = document.createElement('div'); chainEl.className = 'sequence-chain'; const label = document.createElement('div'); label.className = 'sequence-chain__label'; const defaultLetter = String.fromCharCode(65 + (idx % 26)); const labelText = entry.header ? entry.header.replace(/^>/, '') : `Chain ${defaultLetter}`; const chainLetter = entry.header ? deriveChainLetter(labelText, idx) : defaultLetter; const baseType = detectSeqType(entry.seq); const chainSuffix = (baseType === 'dna' || baseType === 'rna') ? 'nt' : 'aa'; const chainLenText = `${entry.seq.length}${chainSuffix}`; const controls = makeChainControls(idx, chainLetter, entries.length, chainLenText); const labelSpan = document.createElement('span'); labelSpan.className = 'seq-chain-label-text'; labelSpan.textContent = labelText; label.replaceChildren(controls, labelSpan); const track = document.createElement('div'); track.className = 'seq-track'; track.dataset.chainStart = String(globalPos); track.dataset.chainLen = String(entry.seq.length); track.dataset.baseType = baseType; const baseSeq = entry.seq; const displayNucType = getPreferredNucType(baseType); if (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown') { const unit = 'aa'; const columns = computeColumnCount(unit, 1); const derivedType = activeNucType || lastNucType || 'dna'; const wantForwardTriplets = Boolean(activeNucType); const wantRevTriplets = Boolean(showRevComp); const wantDerivedNuc = wantForwardTriplets || wantRevTriplets; const derivedNuc = wantDerivedNuc ? getDerivedNucForProtein(entry, idx, baseSeq, derivedType) : ''; const derivedComp = wantRevTriplets ? complementSeq(derivedNuc, derivedType) : ''; for (let start = 0; start < baseSeq.length; start += columns) { const chunk = baseSeq.slice(start, start + columns); const chunkLen = chunk.length; const padLen = chunkLen; const rowTemplate = buildRowTemplate(padLen); const rowBlock = document.createElement('div'); rowBlock.className = 'seq-rowblock'; if (showRulers) rowBlock.appendChild(buildRulerRow(rowTemplate, unit, globalPos + start, chunkLen, padLen, globalPos)); if (showProtein) { const aaRow = document.createElement('div'); aaRow.className = 'seq-row seq-row--protein'; setRowUnitVars(aaRow, unit); aaRow.style.gridTemplateColumns = rowTemplate; for (let i = 0; i < padLen; i++) { const span = document.createElement('span'); const pos = globalPos + start + i; span.classList.add('seq-cell'); span.dataset.track = 'protein'; const ch = chunk[i]; span.dataset.unit = ch; const display = (proteinViewMode === 2) ? aaToThree(ch) : ch; setCellText(span, display); span.setAttribute('data-pos-start', String(pos)); span.setAttribute('data-pos-end', String(pos)); applyColorToResidue(span, ch, pos); applyHighlightsToRange(span, pos, pos, 'protein'); registerTrackCell(span, pos, pos, 'protein'); span.style.gridColumn = String(gridColumnForUnitIndex(i + 1)); aaRow.appendChild(span); } addDirectionLabels(aaRow, 'N-', '-C'); rowBlock.appendChild(aaRow); } if (wantDerivedNuc) { if (wantForwardTriplets) { const triRow = document.createElement('div'); triRow.className = 'seq-triplet-row seq-triplet-row--derived'; setRowUnitVars(triRow, unit); triRow.style.gridTemplateColumns = rowTemplate; for (let i = 0; i < padLen; i++) { const aaPos = globalPos + start + i; const cell = document.createElement('div'); cell.className = 'seq-triplet-cell'; cell.dataset.track = 'triplet'; cell.style.gridColumn = String(gridColumnForUnitIndex(i + 1)); cell.setAttribute('data-pos-start', String(aaPos)); cell.setAttribute('data-pos-end', String(aaPos)); const codonStart = (aaPos - globalPos) * 3; const codon = derivedNuc.slice(codonStart, codonStart + 3) || '---'; for (let k = 0; k < 3; k++) { const b = codon[k] || '-'; const mini = document.createElement('span'); mini.className = 'seq-triplet-nt'; mini.textContent = b; applyColorToNuc(mini, b); cell.appendChild(mini); } applyHighlightsToRange(cell, aaPos, aaPos, 'triplet'); registerTrackCell(cell, aaPos, aaPos, 'triplet'); triRow.appendChild(cell); } addDirectionLabels(triRow, '5′', '3′'); rowBlock.appendChild(triRow); } if (wantRevTriplets) { const triRevRow = document.createElement('div'); triRevRow.className = 'seq-triplet-row seq-triplet-row--derived-rev'; setRowUnitVars(triRevRow, unit); triRevRow.style.gridTemplateColumns = rowTemplate; for (let i = 0; i < padLen; i++) { const aaPos = globalPos + start + i; const cell = document.createElement('div'); cell.className = 'seq-triplet-cell'; cell.dataset.track = 'triplet-rev'; cell.style.gridColumn = String(gridColumnForUnitIndex(i + 1)); cell.setAttribute('data-pos-start', String(aaPos)); cell.setAttribute('data-pos-end', String(aaPos)); const codonStart = (aaPos - globalPos) * 3; const codon = derivedComp.slice(codonStart, codonStart + 3) || '---'; for (let k = 0; k < 3; k++) { const b = codon[k] || '-'; const mini = document.createElement('span'); mini.className = 'seq-triplet-nt'; mini.textContent = b; applyColorToNuc(mini, b); cell.appendChild(mini); } applyHighlightsToRange(cell, aaPos, aaPos, 'triplet-rev'); registerTrackCell(cell, aaPos, aaPos, 'triplet-rev'); triRevRow.appendChild(cell); } addDirectionLabels(triRevRow, '3′', '5′'); rowBlock.appendChild(triRevRow); } } track.appendChild(rowBlock); } globalPos += baseSeq.length; } else if (baseType === 'dna' || baseType === 'rna') { const unit = 'nt'; const displaySeq = displayNucType === 'rna' ? toRNA(baseSeq) : toDNA(baseSeq); const wantTranslation = Boolean(showProtein); const wantNucRow = (showDNA || showRNA); const wantComp = showRevComp; const alignTo = wantTranslation ? 3 : 1; const columns = computeColumnCount(unit, alignTo); const frameOffset = getChainFrameOffset(globalPos); const activeTranslationRanges = Array.isArray(translationRanges) && translationRanges.length ? translationRanges : null; const compSeq = wantComp ? complementSeq(displaySeq, displayNucType) : ''; let start = 0; let firstChunk = true; while (start < displaySeq.length) { let targetLen = columns; if (wantTranslation && frameOffset !== 0 && firstChunk) { targetLen = Math.max(1, columns - (3 - frameOffset)); } const chunk = displaySeq.slice(start, start + targetLen); const chunkLen = chunk.length; const padLen = chunkLen; const rowTemplate = buildRowTemplate(padLen); const rowBlock = document.createElement('div'); rowBlock.className = 'seq-rowblock'; if (showRulers) rowBlock.appendChild(buildRulerRow(rowTemplate, unit, globalPos + start, chunkLen, padLen, globalPos)); if (wantTranslation) { const transRow = document.createElement('div'); transRow.className = 'seq-row seq-row--translation'; setRowUnitVars(transRow, unit); transRow.style.gridTemplateColumns = rowTemplate; const chunkStart = start; const chunkEnd = start + chunkLen - 1; const offsetInChunk = (frameOffset - (chunkStart % 3) + 3) % 3; const firstCodonStart = chunkStart + offsetInChunk; const renderCodon = (codonStart) => { const codon = displaySeq.slice(codonStart, codonStart + 3); const aa = translateNuc(codon, displayNucType)[0] || ''; const globalStart = globalPos + codonStart; const globalEnd = globalStart + 2; const cell = document.createElement('span'); cell.className = 'seq-cell'; cell.dataset.track = 'translation'; cell.dataset.unit = aa; const display = (proteinViewMode === 2) ? aaToThree(aa) : aa; setCellText(cell, display); cell.setAttribute('data-pos-start', String(globalStart)); cell.setAttribute('data-pos-end', String(globalEnd)); const localNtStart = (codonStart - chunkStart) + 1; const localNtEnd = localNtStart + 2; const startCol = gridColumnForUnitIndex(localNtStart); const endCol = gridColumnForUnitIndex(localNtEnd) + 1; cell.style.gridColumn = `${startCol} / ${endCol}`; if (aa && aa !== 'X' && aa !== '*') applyColorToResidue(cell, aa, globalStart); applyHighlightsToRange(cell, globalStart, globalEnd, 'translation'); registerTrackCell(cell, globalStart, globalEnd, 'translation'); transRow.appendChild(cell); }; if (activeTranslationRanges) { for (let codonStart = chunkStart; codonStart + 2 <= chunkEnd; codonStart += 1) { const globalStart = globalPos + codonStart; const globalEnd = globalStart + 2; const range = activeTranslationRanges.find(r => globalStart >= r.start && globalEnd <= r.end); if (!range) continue; const frameMatch = ((globalStart - range.chainStart - range.frameOffset) % 3 + 3) % 3 === 0; if (!frameMatch) continue; renderCodon(codonStart); } } else { for (let codonStart = firstCodonStart; codonStart + 2 <= chunkEnd; codonStart += 3) { renderCodon(codonStart); } } addDirectionLabels(transRow, 'N-', '-C'); rowBlock.appendChild(transRow); } if (wantNucRow) { const ntRow = document.createElement('div'); ntRow.className = 'seq-row seq-row--nt'; setRowUnitVars(ntRow, unit); ntRow.style.gridTemplateColumns = rowTemplate; for (let i = 0; i < padLen; i++) { const span = document.createElement('span'); span.classList.add('seq-cell', 'seq-cell--nt'); span.dataset.track = 'nt'; span.style.gridColumn = String(gridColumnForUnitIndex(i + 1)); const pos = globalPos + start + i; const b = chunk[i]; setCellText(span, b); span.setAttribute('data-pos-start', String(pos)); span.setAttribute('data-pos-end', String(pos)); applyColorToNuc(span, b); applyHighlightsToRange(span, pos, pos, 'nt'); registerTrackCell(span, pos, pos, 'nt'); ntRow.appendChild(span); } addDirectionLabels(ntRow, '5′', '3′'); rowBlock.appendChild(ntRow); } if (wantComp) { const compChunk = compSeq.slice(start, start + chunkLen); const compRow = document.createElement('div'); compRow.className = 'seq-row seq-row--nt seq-row--revcomp'; setRowUnitVars(compRow, unit); compRow.style.gridTemplateColumns = rowTemplate; for (let i = 0; i < padLen; i++) { const span = document.createElement('span'); span.classList.add('seq-cell', 'seq-cell--nt'); span.dataset.track = 'nt-rev'; span.style.gridColumn = String(gridColumnForUnitIndex(i + 1)); const b = compChunk[i]; const pos = globalPos + start + i; setCellText(span, b); span.setAttribute('data-pos-start', String(pos)); span.setAttribute('data-pos-end', String(pos)); applyColorToNuc(span, b); applyHighlightsToRange(span, pos, pos, 'nt-rev'); registerTrackCell(span, pos, pos, 'nt-rev'); compRow.appendChild(span); } addDirectionLabels(compRow, '3′', '5′'); rowBlock.appendChild(compRow); } track.appendChild(rowBlock); start += chunkLen; firstChunk = false; } globalPos += baseSeq.length; } chainEl.appendChild(label); chainEl.appendChild(track); frag.appendChild(chainEl); }); seqViewer.replaceChildren(frag); if (searchAuthoredByUser) { refreshSearchDecorations({ preserveSelection: true }); } else { clearSearchHits(); searchMatches = []; setSearchCountVisible(false); setSearchCountValue(0); } updateSelectionStyles(); } function updateTranslateButtonState(detected) { if (!translateBtn) return; const hide = detected === 'protein' || detected === 'mixed' || detected === 'unknown'; translateBtn.hidden = hide; } function updateGcMeter() { if (!gcMeter || !gcLabel || !atLabel || !gcBar) return; const processed = processRaw(seqRaw); const seq = String(processed.seq || '').toUpperCase(); const detected = detectSeqType(seq); const gcType = activeNucType || (detected === 'rna' ? 'rna' : 'dna') || lastNucType || 'dna'; const atLabelText = gcType === 'rna' ? 'AU' : 'AT'; const setEmpty = () => { gcMeter.hidden = false; gcLabel.textContent = 'GC --'; atLabel.textContent = `${atLabelText} --`; gcBar.style.width = '0%'; gcMeter.classList.remove('is-warn', 'is-bad'); }; if (!seq.length) { setEmpty(); return; } let working = seq; if (detected === 'protein') { working = backTranslateProtein(seq, gcType); } else if (!(detected === 'dna' || detected === 'rna' || detected === 'mixed')) { setEmpty(); return; } let gc = 0; let at = 0; for (const ch of working) { if (ch === 'G' || ch === 'C') gc += 1; else if (ch === 'A' || ch === 'T' || ch === 'U') at += 1; } const total = gc + at; if (!total) { setEmpty(); return; } gcMeter.hidden = false; const gcPct = Math.round((gc / total) * 100); const atPct = 100 - gcPct; gcLabel.textContent = `GC ${gcPct}%`; atLabel.textContent = `${atLabelText} ${atPct}%`; gcBar.style.width = `${gcPct}%`; gcMeter.classList.remove('is-warn', 'is-bad'); if (gcPct < 35 || gcPct > 65) gcMeter.classList.add('is-bad'); else if (gcPct < 40 || gcPct > 60) gcMeter.classList.add('is-warn'); } function updateSeqViews() { const processed = processRaw(seqRaw); lastSeq = processed.seq; ensureScoreMapLength(processed.length); if (selectionRange && selectionRange.start > processed.length) { selectionRange = null; selectionTrack = null; } const detected = detectSeqType(processed.seq); applyTypePill(detected); const entries = parseEntriesFromRaw(seqRaw); const chainMeta = entries && entries.length ? buildChainMeta(entries) : []; if (Array.isArray(translationRanges) && translationRanges.length) { translationRanges = rebaseTranslationRanges(lastChainMeta, chainMeta, translationRanges); } lastChainMeta = chainMeta; renderSequenceViewer(); updateTranslateButtonState(detected); updateGcMeter(); } function setViewBtnState(btn, isOn) { if (!btn) return; btn.classList.remove('is-blue', 'is-green', 'is-red'); btn.classList.add(isOn ? 'is-green' : 'is-blue'); } function setRulerBtnState(btn) { if (!btn) return; btn.classList.remove('is-green', 'is-red'); btn.classList.add('is-blue'); const mode = showRulers ? String(rulerEvery || 3) : 'off'; btn.setAttribute('data-ruler', mode); btn.title = showRulers ? `Ruler: every ${mode}` : 'Ruler: off'; } function syncViewToggleButtons() { syncActiveNucType(); const hasSeq = processRaw(seqRaw).seq.length > 0; setRulerBtnState(btnRulers); setViewBtnState(btnProtein, hasSeq && showProtein); setViewBtnState(btnDNA, hasSeq && showDNA); setViewBtnState(btnRNA, hasSeq && showRNA); setViewBtnState(btnRevComp, hasSeq && showRevComp); updateGcMeter(); } if (btnRulers) { btnRulers.addEventListener('click', () => { if (isLocked()) return notify('error', 'Editor is locked'); if (!showRulers) { showRulers = true; rulerEvery = 3; } else if (rulerEvery === 3) { showRulers = true; rulerEvery = 5; } else { showRulers = false; rulerEvery = 3; } syncViewToggleButtons(); renderSequenceViewer(); }); } function applyDefaultViewToggles(detectedType) { showRulers = true; rulerEvery = 3; if (detectedType === 'protein') { proteinViewMode = 1; showProtein = true; showDNA = false; showRNA = false; showRevComp = false; lastNucType = lastNucType || 'dna'; } else if (detectedType === 'dna') { proteinViewMode = 0; showProtein = false; showDNA = true; showRNA = false; showRevComp = false; lastNucType = 'dna'; } else if (detectedType === 'rna') { proteinViewMode = 0; showProtein = false; showDNA = false; showRNA = true; showRevComp = false; lastNucType = 'rna'; } else { proteinViewMode = 1; showProtein = true; showDNA = false; showRNA = false; showRevComp = false; lastNucType = lastNucType || 'dna'; } syncViewToggleButtons(); } function setSubtoolbarRightOpen(isOpen) { if (!subtoolbarRight) return; if (subtoolbarHideTimer) { clearTimeout(subtoolbarHideTimer); subtoolbarHideTimer = null; } if (isOpen) { subtoolbarRight.hidden = false; requestAnimationFrame(() => { subtoolbarRight.classList.add('is-open'); }); return; } subtoolbarRight.classList.remove('is-open'); subtoolbarHideTimer = window.setTimeout(() => { if (!subtoolbarRight.classList.contains('is-open')) { subtoolbarRight.hidden = true; } }, 240); } function updateSelectionToolbar() { if (!subtoolbar || !subtoolbarRight) return; const ranges = getActiveRanges(); const hasSelection = Boolean(ranges.length && selectionTrack); const hasSearch = Boolean(searchAuthoredByUser && Array.isArray(searchMatches) && searchMatches.length); setSubtoolbarRightOpen(hasSelection || hasSearch); if (clearBtn) clearBtn.disabled = !(hasSelection || hasSearch); if (highlightTrashBtn) { const overlaps = hasSelection && ranges.some(r => selectionOverlapsHighlight(selectionTrack, r)); highlightTrashBtn.hidden = !overlaps; } } function selectionStringForSearch(){ if (Array.isArray(selectionRanges) && selectionRanges.length){ return selectionRanges.map(r => r.text || r.seq || r.value || '').join(''); } if (selectionRange){ return selectionRange.text || selectionRange.seq || selectionRange.value || ''; } return ''; } if (!suppressSearchInput && searchInput){ const s = selectionStringForSearch(); if (s) { searchInput.value = s; lastSearchQuery = s; searchAuthoredByUser = false; } } function selectedUnitCount() { if (!selectionRange || !selectionTrack) return 0; const nodes = seqViewer.querySelectorAll(`[data-track="${selectionTrack}"].selected`); return nodes.length; } function escapeHtml(s) { return String(s || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function formatMetaHtml(text) { const s = String(text || ''); if (!s.trim()) return ``; let out = ''; let buf = ''; let mode = null; const flush = () => { if (!buf) return; const cls = mode === 'num' ? 'seq-meta-num' : mode === 'alpha' ? 'seq-meta-letter' : 'seq-meta-sep'; out += `${escapeHtml(buf)}`; buf = ''; }; for (const ch of s) { const next = /[0-9]/.test(ch) ? 'num' : /[A-Za-z]/.test(ch) ? 'alpha' : 'sep'; if (next !== mode) { flush(); mode = next; } buf += ch; } flush(); return out || ``; } function getCaretOffset(el) { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return 0; const range = sel.getRangeAt(0); if (!el.contains(range.startContainer)) return 0; const pre = range.cloneRange(); pre.selectNodeContents(el); pre.setEnd(range.startContainer, range.startOffset); return pre.toString().length; } function setCaretOffset(el, offset) { const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null); let node; let remaining = Math.max(0, offset); while ((node = walker.nextNode())) { const len = node.nodeValue.length; if (remaining <= len) { const r = document.createRange(); r.setStart(node, remaining); r.collapse(true); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(r); return; } remaining -= len; } const r = document.createRange(); r.selectNodeContents(el); r.collapse(false); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(r); } function findCellForTrackPos(track, pos) { if (!track || !Number.isFinite(pos)) return null; let el = seqViewer.querySelector(`[data-track="${track}"][data-pos-start="${pos}"]`); if (el) return el; if (track === 'translation') { const codonStart = pos - ((pos - 1) % 3); el = seqViewer.querySelector(`[data-track="translation"][data-pos-start="${codonStart}"]`); if (el) return el; } const candidates = seqViewer.querySelectorAll(`[data-track="${track}"][data-pos-start]`); for (const node of candidates) { const s = Number(node.getAttribute('data-pos-start')); const e = Number(node.getAttribute('data-pos-end') || s); if (pos >= s && pos <= e) return node; } return null; } function getContextFromElement(el) { const trackEl = el?.closest('.seq-track') || null; return { trackEl, chainStart: Number(trackEl?.dataset.chainStart) || 1, chainLen: Number(trackEl?.dataset.chainLen) || 0, baseType: trackEl?.dataset.baseType || null, chainEl: el?.closest('.sequence-chain') || null }; } function getSelectionContextForMeta() { const startEl = findCellForTrackPos(selectionTrack, selectionRange?.start); const endEl = findCellForTrackPos(selectionTrack, selectionRange?.end); const startChain = startEl?.closest('.sequence-chain') || null; const endChain = endEl?.closest('.sequence-chain') || null; const sameChain = Boolean(startChain && endChain && startChain === endChain); const ctx = getContextFromElement(startEl || endEl); return { startEl, endEl, startChain, endChain, sameChain, ...ctx }; } function buildTranslationRanges(ranges, track) { const baseTrack = track || 'nt'; return (ranges || []).map((range) => { const ctx = getContextForRange(baseTrack, range); const chainStart = Number(ctx.chainStart) || 1; const frameOffset = ((range.start - chainStart) % 3 + 3) % 3; return { ...range, chainStart, frameOffset }; }); } function formatSelectionForInput() { if (!selectionRange || !selectionTrack) return ''; const ctx = getSelectionContextForMeta(); const { start, end } = selectionRange; if (selectionTrack === 'translation' && ctx.sameChain) { const aaStart = Math.floor((start - ctx.chainStart) / 3) + 1; const aaEnd = Math.floor((end - ctx.chainStart) / 3) + 1; const startChar = ctx.startEl ? ((ctx.startEl.getAttribute('data-unit') || ctx.startEl.textContent || '').trim()) : ''; const endChar = ctx.endEl ? (ctx.endEl.textContent || '').trim() : ''; const s = startChar ? `${aaStart}${startChar}` : `${aaStart}`; const e = endChar ? `${aaEnd}${endChar}` : `${aaEnd}`; return `${s}-${e}`; } if ((selectionTrack === 'triplet' || selectionTrack === 'triplet-rev') && ctx.sameChain) { const ntStart = ((start - ctx.chainStart) * 3) + 1; const ntEnd = ((end - ctx.chainStart + 1) * 3); const startCodon = ctx.startEl ? (ctx.startEl.textContent || '').replace(/\s+/g, '').trim() : ''; const endCodon = ctx.endEl ? (ctx.endEl.textContent || '').replace(/\s+/g, '').trim() : ''; const startChar = startCodon ? startCodon[0] : ''; const endChar = endCodon ? endCodon.slice(-1) : ''; const s = startChar ? `${ntStart}${startChar}` : `${ntStart}`; const e = endChar ? `${ntEnd}${endChar}` : `${ntEnd}`; return `${s}-${e}`; } const startEl = findCellForTrackPos(selectionTrack, start); const endEl = findCellForTrackPos(selectionTrack, end); const startChar = startEl ? (startEl.textContent || '').trim() : ''; const endChar = endEl ? (endEl.textContent || '').trim() : ''; const s = startChar ? `${start}${startChar}` : `${start}`; const e = endChar ? `${end}${endChar}` : `${end}`; return `${s}-${e}`; } function scrollToPosOnTrack(track, pos) { const el = findCellForTrackPos(track, pos); if (el && typeof el.scrollIntoView === 'function') { el.scrollIntoView({ block: 'center', inline: 'nearest' }); } } function smartScrollAfterSelection(track, startPos) { if (track === 'translation') { const ntEl = findCellForTrackPos('nt', startPos); if (ntEl) return ntEl.scrollIntoView({ block: 'center', inline: 'nearest' }); } scrollToPosOnTrack(track, startPos); } function getActiveRanges() { if (Array.isArray(selectionRanges) && selectionRanges.length) return selectionRanges; if (selectionRange) return [selectionRange]; return []; } function mergeRanges(ranges) { const list = (ranges || []) .map(r => ({ start: Math.min(r.start, r.end), end: Math.max(r.start, r.end) })) .filter(r => Number.isFinite(r.start) && Number.isFinite(r.end)) .sort((a, b) => (a.start - b.start) || (a.end - b.end)); const merged = []; for (const r of list) { const last = merged[merged.length - 1]; if (!last || r.start > last.end + 1) merged.push({ ...r }); else last.end = Math.max(last.end, r.end); } return merged; } function rangesOverlap(start, end, ranges) { if (!ranges || !ranges.length) return true; return ranges.some(r => end >= r.start && start <= r.end); } function getContextForRange(track, range) { const startEl = findCellForTrackPos(track, range?.start); const endEl = findCellForTrackPos(track, range?.end); const startChain = startEl?.closest('.sequence-chain') || null; const endChain = endEl?.closest('.sequence-chain') || null; const sameChain = Boolean(startChain && endChain && startChain === endChain); const ctx = getContextFromElement(startEl || endEl); return { startEl, endEl, startChain, endChain, sameChain, ...ctx }; } function formatRangeForInput(track, range) { if (!range || !track) return ''; const ctx = getContextForRange(track, range); const { start, end } = range; const fmt = (pos, ch) => { const c = String(ch || '').trim(); return c ? `${c}${pos}` : `${pos}`; }; if (track === 'translation' && ctx.sameChain) { const aaStart = Math.floor((start - ctx.chainStart) / 3) + 1; const aaEnd = Math.floor((end - ctx.chainStart) / 3) + 1; const sChar = ctx.startEl ? (ctx.startEl.textContent || '').trim() : ''; const eChar = ctx.endEl ? (ctx.endEl.textContent || '').trim() : ''; if (aaStart === aaEnd) return fmt(aaStart, sChar); return `${fmt(aaStart, sChar)}-${fmt(aaEnd, eChar)}`; } if ((track === 'triplet' || track === 'triplet-rev') && ctx.sameChain) { const ntStart = ((start - ctx.chainStart) * 3) + 1; const ntEnd = ((end - ctx.chainStart + 1) * 3); const startCodon = ctx.startEl ? (ctx.startEl.textContent || '').replace(/\s+/g, '').trim() : ''; const endCodon = ctx.endEl ? (ctx.endEl.textContent || '').replace(/\s+/g, '').trim() : ''; const sChar = startCodon ? startCodon[0] : ''; const eChar = endCodon ? endCodon.slice(-1) : ''; if (ntStart === ntEnd) return fmt(ntStart, sChar); return `${fmt(ntStart, sChar)}-${fmt(ntEnd, eChar)}`; } const startEl = findCellForTrackPos(track, start); const endEl = findCellForTrackPos(track, end); const sChar = startEl ? (startEl.textContent || '').trim() : ''; const eChar = endEl ? (endEl.textContent || '').trim() : ''; if (start === end) return fmt(start, sChar); return `${fmt(start, sChar)}-${fmt(end, eChar)}`; } function formatSelectionForInputMulti() { const ranges = getActiveRanges(); if (!ranges.length || !selectionTrack) return ''; return ranges.map(r => formatRangeForInput(selectionTrack, r)).join(', '); } function formatRangeForMeta(track, range) { const s = formatRangeForInput(track, range); // input uses "-", meta uses " – " return s.replace('-', ' – '); } function collectTextForRange(track, range) { if (!seqViewer || !track || !range) return ''; const nodes = seqViewer.querySelectorAll(`[data-track="${track}"][data-pos-start]`); let out = ''; nodes.forEach(el => { const s = Number(el.getAttribute('data-pos-start')); const e = Number(el.getAttribute('data-pos-end') || s); if (!Number.isFinite(s) || !Number.isFinite(e)) return; if (e < range.start || s > range.end) return; const rawUnit = el.getAttribute('data-unit') || (el.textContent || ''); out += String(rawUnit).replace(/\s+/g, '').trim(); }); return out.toUpperCase(); } function syncSearchToSelection() { if (!searchInput) return; if (selectionFromSearch) return; if (isEditingRange) return; const ranges = getActiveRanges(); if (!ranges.length || !selectionTrack) return; const parts = ranges.map(r => collectTextForRange(selectionTrack, r)).filter(Boolean); if (!parts.length) return; const value = (parts.length === 1) ? parts[0] : parts.join('...'); suppressSearchInput = true; searchInput.value = value; suppressSearchInput = false; searchAuthoredByUser = false; clearSearchHits(); searchMatches = []; setSearchCountVisible(false); setSearchCountValue(0); lastSearchQuery = normalizeSearchQuery(value); lastSearchIndex = -1; updateMeta(); } function updateMeta() { if (isEditingRange) return; const ranges = getActiveRanges(); const hasSelection = Boolean(ranges.length && selectionTrack); const ellipsize = (arr, max = 3) => { if (arr.length <= max) return arr; return arr.slice(0, max).concat(['…']); }; const computeLenForTrackAndRanges = (trk, mergedRanges) => { if (!trk || !mergedRanges || !mergedRanges.length) return null; const span = mergedRanges.reduce((sum, r) => sum + (r.end - r.start + 1), 0); if (trk === 'translation') { const aa = Math.max(1, Math.round(span / 3)); return { units: aa, suffix: 'aa' }; } if (trk === 'triplet' || trk === 'triplet-rev') { return { units: span * 3, suffix: 'nt' }; } if (trk === 'nt' || trk === 'nt-rev') { return { units: span, suffix: 'nt' }; } return { units: span, suffix: 'aa' }; // protein }; if (hasSelection) { const countCells = selectedUnitCount(); if (selectionTrack === 'triplet' || selectionTrack === 'triplet-rev') { lenEl.innerHTML = formatMetaHtml(`${countCells * 3}nt`); } else { const unit = trackUnitSuffix(selectionTrack); lenEl.innerHTML = formatMetaHtml(`${countCells}${unit}`); } } else if (searchAuthoredByUser && Array.isArray(searchMatches) && searchMatches.length) { const trk = chooseBestSearchTrack(); const merged = getSearchRangesForTrack(trk); const info = computeLenForTrackAndRanges(trk, merged); if (info) lenEl.innerHTML = formatMetaHtml(`${info.units}${info.suffix}`); else lenEl.innerHTML = ``; } else { lenEl.innerHTML = ``; } if (hasSelection) { const parts = ranges.map(r => formatRangeForMeta(selectionTrack, r)); posEl.innerHTML = formatMetaHtml(ellipsize(parts, 3).join(', ')); return; } if (searchAuthoredByUser && Array.isArray(searchMatches) && searchMatches.length) { const parts = []; for (const m of searchMatches) { if (!m || !m.track || !Array.isArray(m.ranges)) continue; for (const r of m.ranges) { if (!Number.isFinite(r.start) || !Number.isFinite(r.end)) continue; parts.push(formatRangeForMeta(m.track, r)); } } posEl.innerHTML = parts.length ? formatMetaHtml(ellipsize(parts, 3).join(', ')) : ``; return; } posEl.innerHTML = ``; } function updateSelectionStyles() { const ranges = getActiveRanges(); const nextSelected = new Set(); if (selectionTrack && ranges.length) { const map = trackCellIndex[selectionTrack]; if (map) { for (const r of ranges) { if (!Number.isFinite(r.start) || !Number.isFinite(r.end)) continue; const start = Math.min(r.start, r.end); const end = Math.max(r.start, r.end); for (let pos = start; pos <= end; pos++) { const el = map[pos]; if (el) nextSelected.add(el); } } } } lastSelectedCells.forEach((el) => { if (!nextSelected.has(el)) el.classList.remove('selected'); }); nextSelected.forEach((el) => { if (!lastSelectedCells.has(el)) el.classList.add('selected'); }); lastSelectedCells = nextSelected; updateMeta(); updateSelectionToolbar(); rememberLastSelection(); } function clearSelection() { selectionRanges = []; selectionRange = null; selectionTrack = null; selectionFromSearch = false; lastAppliedSelectionTrack = null; updateSelectionStyles(); markEditorDirty(); } seqViewer?.addEventListener('mousedown', (e) => { if (e.button !== 0) return; const hitCell = e.target?.closest?.('[data-pos-start]'); if (hitCell) return; const hitUI = e.target?.closest?.('button, a, input, select, textarea, [contenteditable="true"], .seq-chain-controls'); if (hitUI) return; if (typeof didDragSelect !== 'undefined' && didDragSelect) return; clearSelection?.(); }); function setSelection(start, end, opts = {}) { const fromSearch = Boolean(opts.fromSearch); const append = Boolean(opts.append); const appendIndex = Number.isFinite(opts.appendIndex) ? opts.appendIndex : null; const next = { start: Math.min(start, end), end: Math.max(start, end) }; let nextRanges; if (append) { const cur = (selectionRanges && selectionRanges.length) ? selectionRanges.slice() : []; const idx = (appendIndex !== null) ? appendIndex : cur.length; cur[idx] = next; nextRanges = cur; } else { nextRanges = [next]; } nextRanges = mergeRanges(nextRanges); selectionFromSearch = fromSearch; selectionRanges = nextRanges; selectionRange = nextRanges.length ? nextRanges[nextRanges.length - 1] : null; lastAppliedSelectionTrack = selectionTrack || null; updateSelectionStyles(); markEditorDirty(); } function getRangeFromEventTarget(target) { let t = target; if (t && t.nodeType === 3) t = t.parentElement; if (!(t instanceof Element)) return null; const el = t.closest('[data-pos-start]'); if (!el) return null; const s = Number(el.getAttribute('data-pos-start')); const e = Number(el.getAttribute('data-pos-end') || s); const track = el.dataset.track || null; if (!Number.isFinite(s) || !Number.isFinite(e)) return null; return { start: s, end: e, track }; } function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); } function getRangeFromPoint(x, y, trackHint = null) { const rect = seqViewer.getBoundingClientRect(); const px = clamp(x, rect.left + 2, rect.right - 2); const py = clamp(y, rect.top + 2, rect.bottom - 2); const stack = document.elementsFromPoint(px, py); for (const el of stack) { const r = getRangeFromEventTarget(el); if (r && r.track && (!trackHint || r.track === trackHint)) return r; } for (const el of stack) { const r = getRangeFromEventTarget(el); if (r && r.track) return r; } return null; } function pushUndoState() { if (lastState === null) { lastState = seqRaw; return; } undoStack.push({ seq: lastState, scores: [...residueScoreMap] }); if (undoStack.length > 200) undoStack.shift(); lastState = seqRaw; } function reorderChain(chainIndex, dir) { const entries = parseEntriesFromRaw(seqRaw); if (!entries.length) return; if (chainIndex < 0 || chainIndex >= entries.length) return; if (dir === 'up' && chainIndex > 0) [entries[chainIndex - 1], entries[chainIndex]] = [entries[chainIndex], entries[chainIndex - 1]]; else if (dir === 'down' && chainIndex < entries.length - 1) [entries[chainIndex + 1], entries[chainIndex]] = [entries[chainIndex], entries[chainIndex + 1]]; else return; if (!isApplyingHistory) { pushUndoState(); redoStack = []; } seqRaw = sanitizeAndGroup(entriesToRaw(entries)).text; updateSeqViews(); lastState = seqRaw; } function deleteResidues(range) { shiftHighlightsAfterDeletion(range); const entries = parseEntriesFromRaw(seqRaw); if (!entries.length) return false; let cursor = 1; entries.forEach((entry) => { const len = entry.seq.length; const overlapStart = Math.max(range.start, cursor); const overlapEnd = Math.min(range.end, cursor + len - 1); if (overlapStart <= overlapEnd) { const localStart = overlapStart - cursor; const localEnd = overlapEnd - cursor; const seqArr = entry.seq.split(''); seqArr.splice(localStart, localEnd - localStart + 1); entry.seq = seqArr.join(''); } cursor += len; }); residueScoreMap.splice(range.start - 1, range.end - range.start + 1); seqRaw = sanitizeAndGroup(entriesToRaw(entries)).text; return true; } function reverseResiduesInRange(range) { const entries = parseEntriesFromRaw(seqRaw); if (!entries.length) return false; let cursor = 1; entries.forEach((entry) => { const len = entry.seq.length; const overlapStart = Math.max(range.start, cursor); const overlapEnd = Math.min(range.end, cursor + len - 1); if (overlapStart <= overlapEnd) { const localStart = overlapStart - cursor; const localEnd = overlapEnd - cursor; const seqArr = entry.seq.split(''); const slice = seqArr.slice(localStart, localEnd + 1).reverse(); seqArr.splice(localStart, localEnd - localStart + 1, ...slice); entry.seq = seqArr.join(''); } cursor += len; }); const scoreSlice = residueScoreMap.slice(range.start - 1, range.end); scoreSlice.reverse(); residueScoreMap.splice(range.start - 1, scoreSlice.length, ...scoreSlice); seqRaw = sanitizeAndGroup(entriesToRaw(entries)).text; return true; } function reverseCodonsInRange(range) { const entries = parseEntriesFromRaw(seqRaw); if (!entries.length) return false; let cursor = 1; entries.forEach((entry) => { const len = entry.seq.length; const overlapStart = Math.max(range.start, cursor); const overlapEnd = Math.min(range.end, cursor + len - 1); if (overlapStart <= overlapEnd) { const localStart = overlapStart - cursor; const localEnd = overlapEnd - cursor; const frameOffset = ((range.start - cursor) % 3 + 3) % 3; const alignedStart = localStart - ((localStart - frameOffset) % 3 + 3) % 3; const alignedEnd = localEnd - ((localEnd - frameOffset) % 3 + 3) % 3 + 2; const safeStart = Math.max(0, alignedStart); const safeEnd = Math.min(len - 1, alignedEnd); const codonCount = Math.floor((safeEnd - safeStart + 1) / 3); if (codonCount > 1) { const sliceLen = codonCount * 3; const actualEnd = safeStart + sliceLen - 1; const seqArr = entry.seq.split(''); const slice = seqArr.slice(safeStart, actualEnd + 1); const codons = []; for (let i = 0; i < slice.length; i += 3) codons.push(slice.slice(i, i + 3)); codons.reverse(); seqArr.splice(safeStart, sliceLen, ...codons.flat()); entry.seq = seqArr.join(''); const globalStart = cursor + safeStart; const scoreSlice = residueScoreMap.slice(globalStart - 1, globalStart - 1 + sliceLen); const scoreCodons = []; for (let i = 0; i < scoreSlice.length; i += 3) scoreCodons.push(scoreSlice.slice(i, i + 3)); scoreCodons.reverse(); residueScoreMap.splice(globalStart - 1, sliceLen, ...scoreCodons.flat()); } } cursor += len; }); seqRaw = sanitizeAndGroup(entriesToRaw(entries)).text; return true; } function handleSequenceText(text, fileName) { const content = String(text || ''); const persistedState = extractPersistedSequenceState(content); if (persistedState) { applySequenceEditorState(persistedState); if (fileName && nameInput) nameInput.value = stripTrailingExtension(fileName); return; } const structured = parseStructureFile(content); if (structured && applyStructuredEntries(structured)) { if (fileName && nameInput) nameInput.value = fileName; return; } if (!isApplyingHistory) { pushUndoState(); redoStack = []; } const combined = seqRaw ? `${seqRaw}\n${content}` : content; seqRaw = sanitizeAndGroup(combined).text; const processed = processRaw(seqRaw); residueScoreMap = new Array(processed.length).fill(null); applyDefaultViewToggles(detectSeqType(processed.seq)); updateSeqViews(); lastState = seqRaw; if (fileName && nameInput) nameInput.value = fileName; } function fallbackCopy(text) { try { const temp = document.createElement('textarea'); temp.value = text; temp.style.position = 'fixed'; temp.style.left = '-9999px'; temp.setAttribute('readonly', ''); document.body.appendChild(temp); temp.select(); document.execCommand('copy'); document.body.removeChild(temp); notify('success', 'Copied'); } catch (err) { notify('error', 'Could not copy'); } } function copyToClipboard(text, okMsg) { if (!text) return notify('error', 'Nothing to copy'); if (navigator.clipboard?.writeText) { navigator.clipboard.writeText(text) .then(() => notify('success', okMsg || 'Copied')) .catch(() => fallbackCopy(text)); } else { fallbackCopy(text); } } function getActiveRangesSafe(){ if (typeof getActiveRanges === 'function') { const r = getActiveRanges(); return Array.isArray(r) ? r : []; } if (Array.isArray(selectionRanges) && selectionRanges.length) return selectionRanges; if (selectionRange) return [selectionRange]; return []; } function mergeRangesLocal(ranges){ const cleaned = (ranges || []) .map(r => ({ start: Math.min(r.start, r.end), end: Math.max(r.start, r.end) })) .filter(r => Number.isFinite(r.start) && Number.isFinite(r.end)) .sort((a,b) => a.start - b.start); if (!cleaned.length) return []; const out = [cleaned[0]]; for (let i=1;i r.start)); const maxEnd = Math.max(...ranges.map(r => r.end)); const unitsSelected = ranges.reduce((acc,r) => acc + (r.end - r.start + 1), 0); const track = selectionTrack || getPrimarySelectableTrack?.() || 'protein'; let count = unitsSelected; if (track === 'translation' || track === 'nt' || track === 'nt-rev') { count = Math.max(1, Math.ceil(unitsSelected / 3)); } const point = (where === 'left') ? minStart : maxEnd; const entries = parseEntriesFromRaw(seqRaw); if (!entries || !entries.length) return; let chainStart = 1; let idx = -1; for (let i=0;i= chainStart && point <= chainEnd) { idx = i; break; } chainStart = chainEnd + 1; } if (idx < 0) return; const entry = entries[idx]; const baseType = detectSeqType(String(entry.seq || '')); if (!isApplyingHistory) { undoStack.push(seqRaw); redoStack = []; } if (baseType === 'dna' || baseType === 'rna') { const localMin = minStart - chainStart; const localMax = maxEnd - chainStart; const codonStart = Math.floor(localMin / 3); const codonEnd = Math.floor(localMax / 3); const off = (where === 'left') ? (codonStart * 3) : ((codonEnd + 1) * 3); const ins = (baseType === 'rna') ? ('NNN'.repeat(count)).replace(/T/g,'U') : ('NNN'.repeat(count)); entry.seq = entry.seq.slice(0, off) + ins + entry.seq.slice(off); } else { const localOff = (where === 'left') ? (minStart - chainStart) : (maxEnd - chainStart + 1); entry.seq = entry.seq.slice(0, localOff) + 'X'.repeat(count) + entry.seq.slice(localOff); } const raw = entriesToRaw(entries.map(e => ({ header: e.header, seq: e.seq }))); seqRaw = sanitizeAndGroup(raw).text; updateSeqViews(); updateSelectionToolbar(); updateEditButtonState?.(); notify?.('success', `Inserted ${count} ${ (baseType === 'dna' || baseType === 'rna') ? 'codon(s)' : 'residue(s)' }`); } insertLeftBtn?.addEventListener('click', () => insertSelectionAsX('left')); insertRightBtn?.addEventListener('click', () => insertSelectionAsX('right')); function getSelectedBoundsFromDOM(){ const nodes = Array.from(seqViewer?.querySelectorAll?.('.selected[data-pos-start], .selected[data-pos]') || []); const vals = []; nodes.forEach(n => { const a = Number(n.dataset.posStart || n.dataset.pos); const b = Number(n.dataset.posEnd || n.dataset.pos); if (Number.isFinite(a)) vals.push(a); if (Number.isFinite(b)) vals.push(b); }); if (!vals.length) return null; return { min: Math.min(...vals), max: Math.max(...vals) }; } function getRangesFromSelectedCells() { if (!seqViewer) return { ranges: [], track: null }; const selected = Array.from(seqViewer.querySelectorAll('.selected[data-pos-start]')); if (!selected.length) return { ranges: [], track: null }; const ranges = selected .map((el) => { const s = Number(el.getAttribute('data-pos-start')); const e = Number(el.getAttribute('data-pos-end') || s); if (!Number.isFinite(s) || !Number.isFinite(e)) return null; return { start: s, end: e }; }) .filter(Boolean); return { ranges: mergeRanges(ranges), track: selected[0]?.dataset?.track || null }; } function mapGlobalPosToEntry(entries, pos1){ let cur = 1; for (let i=0;i= start && pos1 <= end + 1) { return { i, local: pos1 - start + 1 }; } cur = end + 1; } return { i: Math.max(0, entries.length - 1), local: (entries[entries.length - 1]?.seq?.length || 0) + 1 }; } function insertIntoCanvas(where){ if (!seqRaw) { notify?.('error', 'No sequence loaded'); return; } const entries = parseEntriesFromRaw(seqRaw) || []; if (!entries.length) { notify?.('error', 'No sequence loaded'); return; } let anchor = getSelectedBoundsFromDOM(); if (!anchor) { const ranges = (Array.isArray(selectionRanges) && selectionRanges.length) ? selectionRanges : (selectionRange ? [selectionRange] : []); if (!ranges.length) { notify?.('error', 'Select residues / bases first'); return; } anchor = { min: Math.min(...ranges.map(r => r.start)), max: Math.max(...ranges.map(r => r.end)) }; } const totalLen = entries.reduce((acc,e)=>acc + (e.seq?.length || 0), 0); let pos = (where === 'before') ? anchor.min : (anchor.max + 1); pos = Math.max(1, Math.min(totalLen + 1, pos)); const mapped = mapGlobalPosToEntry(entries, pos); const ent = entries[mapped.i]; const local = mapped.local; const baseType = (typeof detectSeqType === 'function') ? detectSeqType(ent.seq) : 'protein'; const track = selectionTrack || getPrimarySelectableTrack?.() || null; const isAAlevel = (track === 'translation' || track === 'protein' || track === 'aa'); let ins = 'X'; if (baseType === 'dna' || baseType === 'rna') ins = isAAlevel ? 'NNN' : 'N'; const s = String(ent.seq || ''); const idx0 = Math.max(0, Math.min(s.length, local - 1)); ent.seq = s.slice(0, idx0) + ins + s.slice(idx0); const raw = entriesToRaw(entries.map(e => ({ header: e.header, seq: e.seq }))); const grouped = sanitizeAndGroup(raw); seqRaw = grouped.text; updateSeqViews(); updateSelectionToolbar(); updateEditButtonState?.(); notify?.('success', `Inserted ${ins.length === 3 ? 'codon' : 'residue'} ${where === 'before' ? 'left' : 'right'}`); } insertLeftBtn?.addEventListener('click', () => insertIntoCanvas('before')); insertRightBtn?.addEventListener('click', () => insertIntoCanvas('after')); function getMetaRangePlain() { const ranges = getActiveRanges(); if (ranges.length && selectionTrack) return formatSelectionForInputMulti(); if (searchAuthoredByUser && Array.isArray(searchMatches) && searchMatches.length) { const t = chooseBestSearchTrack(); const merged = getSearchRangesForTrack(t); return merged.map(r => formatRangeForInput(t, r)).join(', '); } return ''; } posEl?.addEventListener('mousedown', (e) => { if (isEditingRange) return; if (e.shiftKey || e.detail >= 2) return; const txt = getMetaRangePlain(); if (!txt) return; e.preventDefault(); e.stopPropagation(); copyToClipboard(txt, 'Copied range'); }); function getMetaLengthPlain() { const ranges = getActiveRanges(); const unitMultiplier = (trk) => (trk === 'triplet' || trk === 'triplet-rev') ? 3 : 1; if (ranges.length && selectionTrack) { const countCells = selectedUnitCount(); return String(countCells * unitMultiplier(selectionTrack)); } if (searchAuthoredByUser && Array.isArray(searchMatches) && searchMatches.length) { const track = chooseBestSearchTrack(); const merged = getSearchRangesForTrack(track); const units = merged.reduce((sum, r) => sum + (r.end - r.start + 1), 0); return String(units * unitMultiplier(track)); } return ''; } function closeCopyMenu() { if (!copyMenu) return; copyMenu.hidden = true; } function openCopyMenu() { if (!copyMenu || !copyBtn) return; copyMenu.hidden = false; } function collectSelectedTextFromTrack(track) { if (!track) return ''; const selected = Array.from(seqViewer.querySelectorAll(`[data-track="${track}"].selected`)); if (!selected.length) return ''; selected.sort((a, b) => { const as = Number(a.getAttribute('data-pos-start')) || 0; const bs = Number(b.getAttribute('data-pos-start')) || 0; return as - bs; }); return selected.map((el) => (el.getAttribute('data-unit') || el.textContent || '').trim()).join(''); } function exportWhole(kind) { const entries = parseEntriesFromRaw(seqRaw); if (!entries.length) return ''; const parts = []; for (let i = 0; i < entries.length; i++) { const e = entries[i]; const baseType = detectSeqType(e.seq); const header = e.header ? e.header.replace(/^>/, '') : `Chain ${String.fromCharCode(65 + (i % 26))}`; let out = ''; if (kind === 'protein') { if (baseType === 'dna' || baseType === 'rna') { out = translateNuc(e.seq, baseType); } else { out = String(e.seq || '').toUpperCase(); } } else if (kind === 'dna') { if (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown') out = backTranslateProtein(e.seq, 'dna'); else out = toDNA(e.seq); } else if (kind === 'rna') { if (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown') out = backTranslateProtein(e.seq, 'rna'); else out = toRNA(e.seq); } else if (kind === 'revcomp') { const nucType = (baseType === 'rna') ? 'rna' : 'dna'; const nuc = (nucType === 'rna') ? toRNA(e.seq) : toDNA(e.seq); out = reverseComplementSeq(nuc, nucType); } if (!out) continue; parts.push(`>${header}`); parts.push(out); if (i < entries.length - 1) parts.push(''); } return parts.join('\n'); } function selectionOverlapsHighlight(track, range) { if (!track || !range) return false; return highlights.some((h) => h.track === track && !(range.end < h.start || range.start > h.end)); } function shiftHighlightsAfterDeletion(range) { const del = range.end - range.start + 1; for (let i = highlights.length - 1; i >= 0; i--) { const h = highlights[i]; if (h.end < range.start) continue; if (h.start > range.end) { h.start -= del; h.end -= del; continue; } const leftRemains = h.start < range.start; const rightRemains = h.end > range.end; if (leftRemains && rightRemains) { h.end -= del; continue; } if (leftRemains && !rightRemains) { h.end = range.start - 1; continue; } if (!leftRemains && rightRemains) { h.start = range.start; h.end -= del; continue; } highlights.splice(i, 1); } } function removeHighlightsInSelection(track, range) { for (let i = highlights.length - 1; i >= 0; i--) { const h = highlights[i]; if (h.track !== track) continue; if (range.start <= h.end && range.end >= h.start) highlights.splice(i, 1); } } function buildSwatches() { if (!highlightSwatches) return; highlightSwatches.innerHTML = ''; swatchKeys.forEach((key, idx) => { const swatch = document.createElement('button'); swatch.type = 'button'; swatch.className = 'seq-highlight-swatch'; swatch.style.background = palette[key]; swatch.dataset.color = palette[key]; if (idx === 0) swatch.classList.add('is-active'); swatch.addEventListener('click', () => { highlightSwatches.querySelectorAll('.seq-highlight-swatch') .forEach((el) => el.classList.remove('is-active')); swatch.classList.add('is-active'); const color = swatch.dataset.color; const label = highlightNameInput ? highlightNameInput.value.trim() : ''; if (searchAuthoredByUser) { refreshSearchDecorations({ preserveSelection: true }); if (Array.isArray(searchMatches) && searchMatches.length) { const byTrack = new Map(); for (const m of searchMatches) { if (!m || !m.track || !Array.isArray(m.ranges)) continue; if (!byTrack.has(m.track)) byTrack.set(m.track, []); for (const r of m.ranges) { if (!Number.isFinite(r.start) || !Number.isFinite(r.end)) continue; byTrack.get(m.track).push({ start: r.start, end: r.end }); } } renderSequenceViewer(); return; } } const ranges = mergeRanges(getActiveRanges()); if (!ranges.length || !selectionTrack) return; for (const r of ranges) { highlights.push({ start: r.start, end: r.end, color, label, track: selectionTrack }); } renderSequenceViewer(); }); highlightSwatches.appendChild(swatch); }); } let pendingDeleteAssetPaths = []; function openDeleteConfirm(paths = []) { const values = Array.isArray(paths) ? paths : [paths]; pendingDeleteAssetPaths = values.map((path) => String(path || '')).filter(Boolean); if (!deleteBackdrop) return; deleteBackdrop.hidden = false; syncBodyScrollLock(); const count = pendingDeleteAssetPaths.length; if (deleteCountLabel) deleteCountLabel.textContent = String(count); if (deleteText) deleteText.textContent = count === 1 ? 'You are about to permanently delete 1 asset. This cannot be undone.' : `You are about to permanently delete ${count} assets. This cannot be undone.`; if (deleteCountInput) { deleteCountInput.value = ''; deleteCountInput.placeholder = String(count); } setTimeout(() => deleteCountInput?.focus?.(), 0); } function closeDeleteConfirm() { if (!deleteBackdrop) return; deleteBackdrop.hidden = true; pendingDeleteAssetPaths = []; if (deleteCountInput) deleteCountInput.value = ''; syncBodyScrollLock(); } function renderDuplicateInputs(paths = [], existingPaths = []) { if (!duplicateInputsWrap) return []; const values = (Array.isArray(paths) ? paths : [paths]).filter(Boolean); const rows = values.map((path, idx) => { const suggested = buildDuplicatedAssetName(path, existingPaths); const label = toCleanAssetLabel(path); const rowId = `editor-duplicate-name-${idx}`; return ``; }); duplicateInputsWrap.innerHTML = rows.join(''); return [...duplicateInputsWrap.querySelectorAll('[data-duplicate-name-input]')]; } function openDuplicateConfirm(paths = [], existingPaths = []) { if (!duplicateBackdrop) return []; const inputs = renderDuplicateInputs(paths, existingPaths); duplicateBackdrop.hidden = false; syncBodyScrollLock(); setTimeout(() => inputs[0]?.focus?.(), 0); return inputs; } function closeDuplicateConfirm() { if (!duplicateBackdrop) return; duplicateBackdrop.hidden = true; if (duplicateInputsWrap) duplicateInputsWrap.innerHTML = ''; syncBodyScrollLock(); } async function requestDuplicateNames(paths = [], existingPaths = []) { const targets = (Array.isArray(paths) ? paths : [paths]).filter(Boolean); if (!targets.length) return []; if (!duplicateBackdrop || !duplicateInputsWrap || !duplicateConfirm || !duplicateCancel) { return targets.map((path) => buildDuplicatedAssetName(path, existingPaths)); } const inputs = openDuplicateConfirm(targets, existingPaths); return await new Promise((resolve, reject) => { const cleanup = () => { duplicateConfirm.removeEventListener('click', onConfirm); duplicateCancel.removeEventListener('click', onCancel); duplicateClose?.removeEventListener('click', onCancel); duplicateBackdrop.removeEventListener('click', onBackdrop); duplicateInputsWrap.removeEventListener('keydown', onKeydown); }; const onConfirm = () => { const values = inputs.map((input) => String(input.value || '').trim()); const emptyIdx = values.findIndex((v) => !v); if (emptyIdx >= 0) { showToast?.('error', 'Enter a file name for each duplicate'); inputs[emptyIdx]?.focus?.(); return; } cleanup(); closeDuplicateConfirm(); resolve(values); }; const onCancel = () => { cleanup(); closeDuplicateConfirm(); reject(new Error('cancelled')); }; const onBackdrop = (e) => { if (e.target === duplicateBackdrop) onCancel(); }; const onKeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); onConfirm(); } if (e.key === 'Escape') { e.preventDefault(); onCancel(); } }; duplicateConfirm.addEventListener('click', onConfirm); duplicateCancel.addEventListener('click', onCancel); duplicateClose?.addEventListener('click', onCancel); duplicateBackdrop.addEventListener('click', onBackdrop); duplicateInputsWrap.addEventListener('keydown', onKeydown); }); } async function deleteWholeFileNow() { if (!pendingDeleteAssetPaths.length) return closeDeleteConfirm(); const expected = String(pendingDeleteAssetPaths.length); const typed = String(deleteCountInput?.value || '').trim(); if (typed !== expected) { showToast?.('error', `Type ${expected} to confirm deletion`); return; } const toDelete = pendingDeleteAssetPaths.slice(); closeDeleteConfirm(); const assetsPanel = document.querySelector('[data-editor-panel="assets"]'); toDelete.forEach((path) => { const card = [...(assetsPanel?.querySelectorAll('[data-asset-path]') || [])] .find((el) => (el.getAttribute('data-asset-path') || '') === path); if (card) card.remove(); }); try { const { jwt, payload } = await getEditorAuthContext(); await Promise.all(toDelete.map((path) => deleteEditorAsset(payload, path, jwt))); if (toDelete.includes(currentAssetPath)) { clearSequenceState(); currentAssetPath = null; } showToast?.('success', toDelete.length > 1 ? 'Assets deleted' : 'Asset deleted'); suppressAssetsOverlayOnce = true; await refreshAssetsForCurrentContext(); } catch (err) { showToast?.('error', err?.message || 'Failed to delete asset'); suppressAssetsOverlayOnce = true; await refreshAssetsForCurrentContext(); } } if (lockBtn) { lockBtn.addEventListener('click', () => { const locked = !lockBtn.classList.contains('is-locked'); lockBtn.classList.toggle('is-locked', locked); lockBtn.classList.toggle('is-unlocked', !locked); notify('success', locked ? 'Editor locked' : 'Editor unlocked'); }); } if (groupToggleBtn) { groupToggleBtn.addEventListener('click', () => { if (isLocked()) return notify('error', 'Editor is locked'); groupModeIndex = (groupModeIndex + 1) % groupModes.length; groupSize = groupModes[groupModeIndex]; groupToggleBtn.setAttribute('data-mode', String(groupSize)); renderSequenceViewer(); }); } function buildTranslationRangesFromSelection(ranges, meta) { const out = []; for (const r of ranges) { for (const m of meta) { if (m.baseType !== 'dna' && m.baseType !== 'rna') continue; const s = Math.max(r.start, m.start); const t = Math.min(r.end, m.end); if (s > t) continue; const frameOffset = normFrameOffset(s - m.start); out.push({ start: s, end: t, chainStart: m.start, frameOffset }); } } return normalizeAndMergeTranslationRanges(out); } if (translateBtn) { translateBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const entries = parseEntriesFromRaw(seqRaw); if (!entries || !entries.length) { notify?.('error', 'No sequence loaded'); return; } const meta = buildChainMeta(entries); const hasNuc = meta.some(m => m.baseType === 'dna' || m.baseType === 'rna'); if (!hasNuc) { if (!showProtein) { showProtein = true; updateSeqViews(); updateSelectionToolbar(); } return; } let ranges = getMainSelectionRanges(); if (!ranges.length) { const domSel = (typeof getRangesFromSelectedCells === 'function') ? getRangesFromSelectedCells() : null; if (domSel?.ranges?.length && (domSel.track === 'nt' || domSel.track === 'nt-rev' || domSel.track === 'translation')) { ranges = mergeRangesLocal(domSel.ranges); } } if (ranges.length) { const built = buildTranslationRangesFromSelection(ranges, meta); if (!built.length) { notify?.('error', 'Selection has no nucleotide sequence to translate'); return; } translationRanges = applyTranslationRangeOverrides(translationRanges, built); showProtein = true; updateSeqViews(); updateSelectionToolbar(); notify?.('success', 'Translated selection'); return; } translationRanges = null; showProtein = true; translationFrameOffset = normFrameOffset(translationFrameOffset + 1); updateSeqViews(); updateSelectionToolbar(); notify?.('success', `Frame +${translationFrameOffset + 1}`); }, true); } if (zoomInBtn && zoomOutBtn) { const editorBody = document.querySelector('.seq-editor-body'); const applyZoom = () => { if (!editorBody) return; editorBody.style.setProperty('--seq-font-size', `${fontSize}px`); editorBody.style.setProperty('--seq-cell-size', `${fontSize * 1.6}px`); const ntFont = Math.max(12, Math.round(fontSize * 0.82)); editorBody.style.setProperty('--seq-nt-font-size', `${ntFont}px`); editorBody.style.setProperty('--seq-nt-cell-size', `${ntFont * 1.45}px`); requestAnimationFrame(() => renderSequenceViewer()); }; zoomInBtn.addEventListener('click', () => { fontSize = Math.min(fontSize + 2, 34); applyZoom(); }); zoomOutBtn.addEventListener('click', () => { fontSize = Math.max(fontSize - 2, 16); applyZoom(); }); applyZoom(); } if (undoBtn) { undoBtn.addEventListener('click', () => { if (isLocked()) return notify('error', 'Editor is locked'); if (!undoStack.length) return notify('error', 'Nothing to undo'); const state = undoStack.pop(); redoStack.push({ seq: seqRaw, scores: [...residueScoreMap] }); isApplyingHistory = true; seqRaw = state.seq; residueScoreMap = state.scores ? [...state.scores] : []; updateSeqViews(); isApplyingHistory = false; lastState = seqRaw; notify('success', 'Undid change'); }); } if (redoBtn) { redoBtn.addEventListener('click', () => { if (isLocked()) return notify('error', 'Editor is locked'); if (!redoStack.length) return notify('error', 'Nothing to redo'); const state = redoStack.pop(); undoStack.push({ seq: seqRaw, scores: [...residueScoreMap] }); isApplyingHistory = true; seqRaw = state.seq; residueScoreMap = state.scores ? [...state.scores] : []; updateSeqViews(); isApplyingHistory = false; lastState = seqRaw; notify('success', 'Redid change'); }); } if (mutateBtn) { mutateBtn.addEventListener('click', (e) => { e.stopPropagation(); mutateBtn.classList.toggle('is-active'); }); } if (copyBtn) { copyBtn.addEventListener('click', (e) => { e.stopPropagation(); if (selectionRange && selectionTrack) { const ranges = mergeRanges(getActiveRanges()); const parts = ranges .map(r => collectTextForRange(selectionTrack, r)) .filter(Boolean); const text = (parts.length <= 1) ? (parts[0] || '') : parts.join('...'); return copyToClipboard(text, 'Copied selection'); } if (!seqRaw) return notify('error', 'Nothing to copy'); openCopyMenu(); }); } if (copyProteinWholeBtn) copyProteinWholeBtn.addEventListener('click', () => { closeCopyMenu(); copyToClipboard(exportWhole('protein'), 'Copied protein'); }); if (copyDNAWholeBtn) copyDNAWholeBtn.addEventListener('click', () => { closeCopyMenu(); copyToClipboard(exportWhole('dna'), 'Copied DNA'); }); if (copyRNAWholeBtn) copyRNAWholeBtn.addEventListener('click', () => { closeCopyMenu(); copyToClipboard(exportWhole('rna'), 'Copied RNA'); }); if (copyRevWholeBtn) copyRevWholeBtn.addEventListener('click', () => { closeCopyMenu(); copyToClipboard(exportWhole('revcomp'), 'Copied reverse complement'); }); document.addEventListener('click', (e) => { if (!copyMenu || copyMenu.hidden) return; const t = e.target; if (!(t instanceof Element)) return; if (t.closest('#seq-copy-menu') || t.closest('#seq-copy-btn')) return; closeCopyMenu(); }); if (clearBtn) { clearBtn.addEventListener('click', () => { if (isLocked()) return notify('error', 'Editor is locked'); if (!seqRaw) return notify('error', 'Nothing to delete'); const selRanges = mergeRanges(getActiveRanges()); const hasSelection = Boolean(selRanges.length && selectionTrack); const canDeleteSearchMatches = Boolean(!hasSelection && searchAuthoredByUser); let rangesToDelete = []; if (hasSelection) { rangesToDelete = selRanges.slice(); } else if (canDeleteSearchMatches) { refreshSearchDecorations({ preserveSelection: true }); if (!Array.isArray(searchMatches) || !searchMatches.length) { return notify('error', 'No match found'); } rangesToDelete = mergeRanges( searchMatches.flatMap(m => (m.ranges || []).map(r => ({ start: r.start, end: r.end }))) ); } else { return notify('error', 'Select a region to delete'); } if (!rangesToDelete.length) return notify('error', 'Nothing to delete'); pushUndoState(); redoStack = []; rangesToDelete.sort((a, b) => b.start - a.start); for (const r of rangesToDelete) deleteResidues(r); clearSelection(); hardClearSearchUI(); updateSeqViews(); lastState = seqRaw; notify('success', rangesToDelete.length > 1 ? 'Deleted selections' : 'Deleted selection'); }); } if (headerFileInput) { headerFileInput.addEventListener('change', (e) => { if (isLocked()) { notify('error', 'Editor is locked'); headerFileInput.value = ''; return; } const file = e.target.files && e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => handleSequenceText(ev.target?.result ? ev.target.result : '', file.name); reader.readAsText(file); headerFileInput.value = ''; }); } seqViewer.addEventListener('dragstart', (e) => e.preventDefault()); seqViewer.addEventListener('dragover', (e) => e.preventDefault()); seqViewer.addEventListener('drop', (e) => { e.preventDefault(); if (isLocked()) return; const dt = e.dataTransfer; if (!dt) return; const text = dt.getData('text/plain'); if (text) return handleSequenceText(text); const file = dt.files && dt.files[0]; if (file) { const reader = new FileReader(); reader.onload = (ev) => handleSequenceText(ev.target?.result ? ev.target.result : '', file.name); reader.readAsText(file); } }); function mergeRangesSimple(ranges) { const cleaned = (ranges || []) .map(r => ({ start: Math.min(r.start, r.end), end: Math.max(r.start, r.end) })) .filter(r => Number.isFinite(r.start) && Number.isFinite(r.end)) .sort((a, b) => (a.start - b.start) || (a.end - b.end)); if (!cleaned.length) return []; const out = [cleaned[0]]; for (let i = 1; i < cleaned.length; i++) { const cur = cleaned[i]; const last = out[out.length - 1]; if (cur.start <= last.end + 1) last.end = Math.max(last.end, cur.end); else out.push(cur); } return out; } function getAnySelectionRanges() { if (typeof getActiveRanges === 'function') { const r = getActiveRanges(); if (Array.isArray(r) && r.length) return mergeRangesSimple(r); } if (Array.isArray(selectionRanges) && selectionRanges.length) return mergeRangesSimple(selectionRanges); if (selectionRange) return mergeRangesSimple([selectionRange]); return []; } function getSelectionForAction() { let ranges = getAnySelectionRanges(); let track = selectionTrack || (typeof getPrimarySelectableTrack === 'function' ? getPrimarySelectableTrack() : null); if ((!ranges.length || !track) && lastGoodSelection?.ranges?.length) { ranges = mergeRangesSimple(lastGoodSelection.ranges); track = lastGoodSelection.track || track; } if (!ranges.length) return null; return { track: track || null, ranges }; } ['mouseup', 'keyup', 'touchend'].forEach((evt) => { document.addEventListener(evt, () => { try { rememberLastSelection?.(); } catch (_) {} }, { passive: true }); }); function reverseSelectionApply() { if (isLocked?.()) { notify?.('error', 'Unlock to edit'); return; } const sel = getSelectionForAction(); if (!sel) { notify?.('error', 'Select something first'); return; } const entries = parseEntriesFromRaw(seqRaw); if (!entries || !entries.length) { notify?.('error', 'No sequence loaded'); return; } const meta = buildChainMeta(entries); // you already have this function const out = entries.map(e => ({ header: e.header || null, seq: String(e.seq || '').toUpperCase() })); for (const R of sel.ranges) { for (const m of meta) { const ovStart = Math.max(m.start, R.start); const ovEnd = Math.min(m.end, R.end); if (ovStart > ovEnd) continue; const localStart1 = ovStart - m.start + 1; // 1-based const localEnd1 = ovEnd - m.start + 1; // 1-based const idx = m.i; const baseType = m.baseType; if (baseType === 'protein' || baseType === 'mixed' || baseType === 'unknown') { const a0 = localStart1 - 1; const b0 = localEnd1 - 1; out[idx].seq = reverseStringSpan(out[idx].seq, a0, b0); try { reverseProteinOverrideCodons?.(out[idx], idx, localStart1, localEnd1); } catch (_) {} continue; } if (baseType === 'dna' || baseType === 'rna') { const a0 = localStart1 - 1; const b0 = localEnd1 - 1; out[idx].seq = reverseStringSpan(out[idx].seq, a0, b0); } } } if (!isApplyingHistory) { pushUndoState(); redoStack = []; } const rawOut = (typeof entriesToRaw === 'function') ? entriesToRaw(out) : entriesToRawFallback(out); seqRaw = sanitizeAndGroup(rawOut).text; updateSeqViews(); updateSelectionToolbar(); updateEditButtonState?.(); lastState = seqRaw; try { if (typeof clearSelection === 'function') clearSelection(); if (typeof setSelection === 'function') { const rr = sel.ranges; if (rr.length) { selectionTrack = sel.track || selectionTrack || null; setSelection(rr[0].start, rr[0].end); for (let i = 1; i < rr.length; i++) { setSelection(rr[i].start, rr[i].end, { append: true, appendIndex: i }); } } } } catch (_) {} notify?.('success', 'Reversed'); } reverseBtn?.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); reverseSelectionApply(); }, true); function beginSelectionDrag(point, opts = {}) { closeCopyMenu(); didDragSelect = false; blurEditorFieldsForSelection(); const r = getRangeFromPoint(point.x, point.y, null); if (!r || !r.track) { clearSelection(); hardClearSearchUI(); dragAnchorStart = null; stopDragAutoScroll(); return; } if (opts.event) { opts.event.preventDefault(); } const append = Boolean(opts.append); const keepSearchMode = Boolean(searchAuthoredByUser && selectionFromSearch); downRange = r; downWasSelected = selectionContainsRange(r.track, r); if (!append) { selectionTrack = r.track; if (!(keepSearchMode && selectionTrack === r.track)) { selectionFromSearch = false; } } else { if (selectionTrack && selectionTrack !== r.track) return; selectionTrack = selectionTrack || r.track; } dragStartPointer = { x: point.x, y: point.y }; dragAnchorStart = r.start; dragAppending = append; dragAppendIndex = append ? selectionRanges.length : null; dragPointer = { x: point.x, y: point.y }; startDragAutoScroll(); suppressNextClick = true; if (downWasSelected) return; setSelection( r.start, r.end, append ? { append: true, appendIndex: dragAppendIndex } : {} ); } function updateSelectionDrag(point, opts = {}) { if (dragAnchorStart === null) return; if (opts.requireButton && (opts.buttons & 1) === 0) return; if (dragStartPointer) { const dx = point.x - dragStartPointer.x; const dy = point.y - dragStartPointer.y; const dist = Math.hypot(dx, dy); if (dist < 4) return; // threshold } if (opts.event && opts.preventDefault) { opts.event.preventDefault(); } didDragSelect = true; dragPointer = { x: point.x, y: point.y }; const r = getRangeFromPoint(point.x, point.y, selectionTrack || null); if (!r || !r.track) return; if (selectionTrack && r.track !== selectionTrack) return; setSelection( dragAnchorStart, r.end, dragAppending ? { append: true, appendIndex: dragAppendIndex } : {} ); } function endSelectionDrag() { const clickedRange = downRange; const wasSelected = downWasSelected; dragStartPointer = null; downRange = null; downWasSelected = false; dragAnchorStart = null; stopDragAutoScroll(); if (didDragSelect) { seqViewer.dataset.justDragged = '1'; setTimeout(() => { delete seqViewer.dataset.justDragged; }, 0); } if (!didDragSelect && clickedRange && wasSelected && selectionTrack === clickedRange.track) { const keepSearchMode = Boolean(searchAuthoredByUser && selectionFromSearch); if (keepSearchMode) { searchSelectionCustom = true; } else { selectionFromSearch = false; } const cur = mergeRanges(getActiveRanges()); const next = subtractRangeFromRanges(cur, clickedRange); selectionRanges = next; selectionRange = next.length ? next[next.length - 1] : null; if (!next.length) { selectionTrack = null; updateSelectionStyles(); if (!searchAuthoredByUser) hardClearSearchUI(); } else { updateSelectionStyles(); syncSearchToSelection(); } dragAppending = false; dragAppendIndex = null; return; } if (!selectionFromSearch && getActiveRanges().length && selectionTrack) { syncSearchToSelection(); } dragAppending = false; dragAppendIndex = null; } seqViewer.addEventListener('mousedown', (e) => { beginSelectionDrag({ x: e.clientX, y: e.clientY }, { append: e.ctrlKey || e.metaKey, event: e }); }); seqViewer.addEventListener('touchstart', (e) => { if (e.touches.length !== 1 || activeTouchId !== null) return; const touch = e.touches[0]; activeTouchId = touch.identifier; beginSelectionDrag({ x: touch.clientX, y: touch.clientY }, { append: false, event: e }); }, { passive: false }); seqViewer.addEventListener('touchmove', (e) => { if (activeTouchId === null) return; const touch = Array.from(e.touches).find((t) => t.identifier === activeTouchId); if (!touch) return; updateSelectionDrag( { x: touch.clientX, y: touch.clientY }, { requireButton: false, event: e, preventDefault: true } ); }, { passive: false }); seqViewer.addEventListener('touchend', (e) => { if (activeTouchId === null) return; const ended = Array.from(e.changedTouches).some((t) => t.identifier === activeTouchId); if (!ended) return; activeTouchId = null; endSelectionDrag(); }); seqViewer.addEventListener('touchcancel', (e) => { if (activeTouchId === null) return; const canceled = Array.from(e.changedTouches).some((t) => t.identifier === activeTouchId); if (!canceled) return; activeTouchId = null; endSelectionDrag(); }); let dragScrollRaf = null; seqViewer.addEventListener('scroll', () => { if (dragAnchorStart === null || !dragPointer) return; if (dragScrollRaf) cancelAnimationFrame(dragScrollRaf); dragScrollRaf = requestAnimationFrame(() => { const r = getRangeFromPoint(dragPointer.x, dragPointer.y, selectionTrack || null); if (r && r.track && (!selectionTrack || r.track === selectionTrack)) { setSelection( dragAnchorStart, r.end, dragAppending ? { append: true, appendIndex: dragAppendIndex } : {} ); } }); }); seqViewer.addEventListener('mousemove', (e) => { updateSelectionDrag( { x: e.clientX, y: e.clientY }, { requireButton: true, buttons: e.buttons, event: e } ); }); window.addEventListener('mouseup', () => { endSelectionDrag(); }); seqViewer.addEventListener('click', (e) => { if (seqViewer.dataset.justDragged === '1') return; if (suppressNextClick) { suppressNextClick = false; return; } const r = getRangeFromEventTarget(e.target); if (!r || !r.track) { clearSelection(); return; } const append = e.ctrlKey || e.metaKey; if (!append) { selectionTrack = r.track; selectionFromSearch = false; setSelection(r.start, r.end); } else { if (selectionTrack && selectionTrack !== r.track) return; selectionTrack = selectionTrack || r.track; setSelection(r.start, r.end, { append: true }); } smartScrollAfterSelection(selectionTrack, r.start); syncSearchToSelection(); }); if (proteinColorSelect) { proteinColorSelect.addEventListener('change', (e) => { proteinColorMode = e.target.value; renderSequenceViewer(); }); } if (ntColorSelect) { ntColorSelect.addEventListener('change', (e) => { ntScheme = e.target.value; renderSequenceViewer(); }); } if (btnProtein) { btnProtein.addEventListener('click', () => { proteinViewMode = (proteinViewMode + 1) % 3; showProtein = (proteinViewMode !== 0); btnProtein.dataset.mode = proteinViewMode === 2 ? 'three' : (proteinViewMode === 1 ? 'one' : 'off'); syncViewToggleButtons(); renderSequenceViewer(); }); } if (btnDNA) { btnDNA.addEventListener('click', () => { showDNA = !showDNA; if (showDNA) showRNA = false; syncViewToggleButtons(); renderSequenceViewer(); }); } if (btnRNA) { btnRNA.addEventListener('click', () => { showRNA = !showRNA; if (showRNA) showDNA = false; syncViewToggleButtons(); renderSequenceViewer(); }); } if (btnRevComp) { btnRevComp.addEventListener('click', () => { showRevComp = !showRevComp; syncViewToggleButtons(); renderSequenceViewer(); }); } buildSwatches(); if (highlightTrashBtn) { highlightTrashBtn.addEventListener('click', () => { if (!selectionTrack) return; const ranges = mergeRanges(getActiveRanges()); if (!ranges.length) return; for (const r of ranges) { if (selectionOverlapsHighlight(selectionTrack, r)) { removeHighlightsInSelection(selectionTrack, r); } } renderSequenceViewer(); }); } if (seqModalApply && seqModalInput) { seqModalApply.addEventListener('click', () => { if (isLocked()) return notify('error', 'Editor is locked'); const text = seqModalInput.value; if (!text.trim()) return notify('error', 'Nothing to apply'); pushUndoState(); redoStack = []; seqRaw = sanitizeAndGroup(text).text; const processed = processRaw(seqRaw); residueScoreMap = new Array(processed.length).fill(null); applyDefaultViewToggles(detectSeqType(processed.seq)); clearSelection(); updateSeqViews(); lastState = seqRaw; notify('success', 'Sequence updated'); closeSeqModal(); }); } if (seqModalClear && seqModalInput) { seqModalClear.addEventListener('click', () => { seqModalInput.value = ''; seqModalInput.focus(); }); } if (deleteClose) deleteClose.addEventListener('click', closeDeleteConfirm); if (deleteCancel) deleteCancel.addEventListener('click', closeDeleteConfirm); if (deleteConfirm) deleteConfirm.addEventListener('click', deleteWholeFileNow); if (deleteBackdrop) { deleteBackdrop.addEventListener('click', (e) => { if (e.target === deleteBackdrop) closeDeleteConfirm(); }); } document.querySelectorAll('[data-new-type]').forEach((btn) => { btn.addEventListener('click', () => { const type = btn.getAttribute('data-new-type') || 'sequence'; newTypeBackdrop.hidden = true; syncBodyScrollLock(); selectEditorTab(type); }); }); document.getElementById('editor-new-type-close')?.addEventListener('click', () => { newTypeBackdrop.hidden = true; syncBodyScrollLock(); }); newTypeBackdrop?.addEventListener('click', (e) => { if (e.target === newTypeBackdrop) { newTypeBackdrop.hidden = true; syncBodyScrollLock(); } }); document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; if (seqModalBackdrop && !seqModalBackdrop.hidden) closeSeqModal(); if (deleteBackdrop && !deleteBackdrop.hidden) closeDeleteConfirm(); if (duplicateBackdrop && !duplicateBackdrop.hidden) closeDuplicateConfirm(); closeCopyMenu(); }); const ZWSP = '\u200B'; let rangeLiveTimer = null; let lastRangeLiveKey = ''; document.addEventListener('pointerdown', (e) => { const t = e.target; if (!(t instanceof Element)) return; if (t.closest('#seq-viewer')) return; const lenWrap = lenEl ? (lenEl.closest('.seq-meta-box--len') || lenEl.parentElement) : null; const inLen = lenEl ? (t === lenEl || (lenWrap && lenWrap.contains(t))) : false; const searchWrap = searchInput ? (searchInput.closest('.seq-search') || searchInput.parentElement) : null; const inSearch = searchInput ? (t === searchInput || (searchWrap && searchWrap.contains(t))) : false; const rangeWrap = posEl ? (posEl.closest('.seq-meta-box--range') || posEl.parentElement) : null; const inRange = posEl ? (t === posEl || (rangeWrap && rangeWrap.contains(t))) : false; const inColorModes = Boolean( t.closest('#seq-protein-color-mode') || t.closest('#seq-nt-color-mode') ); const inHighlightLabel = Boolean( t.closest('#seq-highlight-name') ); const clickedMeta = inSearch || inRange || inLen || inColorModes || inHighlightLabel; const preserveSelection = Boolean( t.closest('#seq-copy-btn') || t.closest('#seq-clear-btn') || t.closest('#seq-undo-btn') || t.closest('#seq-redo-btn') || t.closest('#seq-zoom-in-btn') || t.closest('#seq-zoom-out-btn') || t.closest('#seq-translate') || t.closest('#seq-mutate-btn') || t.closest('#seq-clear-highlight') || t.closest('#seq-highlight-swatches') || t.closest('#seq-highlight-name') || t.closest('#seq-protein-color-mode') || t.closest('#seq-nt-color-mode') || t.closest('#seq-copy-menu') || t.closest('#editor-delete-backdrop') || t.closest('#editor-delete-confirm') || t.closest('#editor-delete-cancel') || t.closest('#editor-delete-close') || t.closest('#editor-duplicate-backdrop') || t.closest('#editor-duplicate-confirm') || t.closest('#editor-duplicate-cancel') || t.closest('#editor-duplicate-close') ); if (!preserveSelection && !clickedMeta && getActiveRanges().length) { clearSelection(); } if (!clickedMeta) { const hasSearch = Boolean((searchInput?.value || '').trim()) || searchAuthoredByUser || (Array.isArray(searchMatches) && searchMatches.length); if (hasSearch) hardClearSearchUI(); } }, true); function scheduleLiveRangeApply() { if (rangeLiveTimer) clearTimeout(rangeLiveTimer); rangeLiveTimer = setTimeout(() => { if (!isEditingRange) return; const cleaned = (posEl.textContent || '').replace(/\u200B/g, '').trim(); const processedLen = processRaw(seqRaw).length; if (processedLen <= 0) return; const { track, maxInputLen, toSelectionRange } = getRangeInputContext(); const parsedList = parseUserRangesMulti(cleaned, maxInputLen, { allowPartial: true }); if (!parsedList) return; const mapped = []; for (const p of parsedList) { const sel = toSelectionRange(p); if (sel && Number.isFinite(sel.start) && Number.isFinite(sel.end)) mapped.push(sel); } if (!mapped.length) return; const merged = mergeRanges(mapped); const key = `${track}:${merged.map(r => `${r.start}-${r.end}`).join(',')}`; if (key === lastRangeLiveKey) return; lastRangeLiveKey = key; selectionTrack = track; selectionFromSearch = false; selectionRanges = merged; selectionRange = merged[merged.length - 1] || null; lastAppliedSelectionTrack = selectionTrack || null; updateSelectionStyles(); }, 120); } posEl.addEventListener('focus', () => { isEditingRange = true; lastRangeLiveKey = ''; let raw = ''; if (getActiveRanges().length && selectionTrack) { raw = formatSelectionForInputMulti(); } else if (searchAuthoredByUser && Array.isArray(searchMatches) && searchMatches.length) { const t = chooseBestSearchTrack(); const merged = getSearchRangesForTrack(t); raw = merged.map(r => formatRangeForInput(t, r)).join(', '); } posEl.textContent = raw || ZWSP; const r = document.createRange(); r.selectNodeContents(posEl); if (!raw) r.collapse(false); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(r); scheduleLiveRangeApply(); }); posEl.addEventListener('input', () => { if (!isEditingRange) return; const caret = getCaretOffset(posEl); let raw = (posEl.textContent || '').replace(/\n/g, '').replace(/\u200B/g, ''); if (!raw) raw = ZWSP; if ((posEl.textContent || '') !== raw) { posEl.textContent = raw; setCaretOffset(posEl, Math.min(caret, raw.length)); } scheduleLiveRangeApply(); }); posEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); posEl.blur(); } if (e.key === 'Escape') { e.preventDefault(); isEditingRange = false; posEl.blur(); updateMeta(); } }); posEl.addEventListener('blur', () => { if (!isEditingRange) return; isEditingRange = false; const processedLen = processRaw(seqRaw).length; if (processedLen <= 0) { updateMeta(); return; } const cleaned = (posEl.innerText || '').replace(/\u200B/g, '').trim(); const { track, maxInputLen, toSelectionRange } = getRangeInputContext(); const parsedList = parseUserRangesMulti(cleaned, maxInputLen, { allowPartial: false }); if (!parsedList) { updateMeta(); return; } const mapped = []; for (const p of parsedList) { const sel = toSelectionRange(p); if (sel && Number.isFinite(sel.start) && Number.isFinite(sel.end)) mapped.push(sel); } if (!mapped.length) { updateMeta(); return; } const merged = mergeRanges(mapped); selectionTrack = track; selectionFromSearch = false; selectionRanges = merged; selectionRange = merged[merged.length - 1] || null; lastAppliedSelectionTrack = selectionTrack || null; updateSelectionStyles(); smartScrollAfterSelection(selectionTrack, merged[0].start); }); attachCanvasFileDrop(); async function persistCurrentSequenceDraft() { const payload = { name: nameInput?.value?.trim() || 'untitled-sequence', sequence: seqRaw, highlights, proteinColorMode, ntScheme }; try { const resp = await fetch('/api/editor/save-sequence-draft', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); notify('success', 'Saved draft'); } catch (err) { notify('error', 'Save draft failed (endpoint missing?)'); } } function regroupOnResize() { renderSequenceViewer(); } function init() { const initialRaw = seqViewer.innerText || ''; seqRaw = sanitizeAndGroup(initialRaw).text; const processed = processRaw(seqRaw); applyDefaultViewToggles(detectSeqType(processed.seq)); updateSeqViews(); lastState = seqRaw; undoStack = []; redoStack = []; clearSelection(); syncViewToggleButtons(); groupToggleBtn?.setAttribute('data-mode', String(groupSize)); if (clearBtn) clearBtn.disabled = true; if (isMemberSignedIn) refreshAssetsForCurrentContext().catch(() => {}); } init(); window.addEventListener('vici:ctxchange', async (e) => { if (isRevertingContext) return; if (hasSequenceWorkingState()) { const member = await getCurrentMemberData().catch(() => null); if (member?.id) { isRevertingContext = true; window.ViciContext?.set?.(activeScopeId || `user:${member.id}`); isRevertingContext = false; } showToast?.('error', 'Close and save your open file before switching context'); return; } await handleContextSwitch(String(e?.detail || '')); }); window.addEventListener('resize', () => { if (resizeTimer) clearTimeout(resizeTimer); resizeTimer = setTimeout(regroupOnResize, 80); }); });