'use client'; import { useEffect, useMemo, useState } from 'react'; import { motion } from 'framer-motion'; import { CircleNotch as LoaderCircle, DownloadSimple as Download, MagnifyingGlass as Search, } from '@phosphor-icons/react'; import { ResponsiveContainer, ScatterChart, Scatter, XAxis, YAxis, Tooltip, CartesianGrid, ReferenceLine, Brush, } from 'recharts'; import { fadeUp } from '@/lib/animations'; import { fetchSequence, runDotPlot } from '@/lib/api'; import { extractErrorMessage } from '@/lib/errors'; import { downloadText } from '@/lib/export-utils'; import { useAuditTrail } from '@/hooks/useAuditTrail'; import { BackButton, CriticalButton, ClaySlider, ClayToggle, FlatTextarea, PageHeader } from '@/components/ui'; import type { DotPlotResult, DotPlotFeatures } from '@/types/pipeline'; const SCORING_OPTIONS = ['identity', 'blosum62', 'blosum50', 'blosum45', 'pam30', 'pam70', 'pam250']; const LINE = { diagonal: 'rgba(168,85,247,0.55)', repeat: '#FBBF24', inverted: '#34D399', }; const SAMPLE_A = '>p53_human\nMEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEAPRMPEAAPPVAPAPAAPTPAAPAPAPSWPLSSSVPSQKTYQGSYGFRLGFLHSGTAKSVTCTYSPALNKMFCQLAKTCPVQLWVDSTPPPGTRVRAMAIYKQSQHMTEVVRRCPHHERCSDSDGLAPPQHLIRVEGNLRVEYLDDRNTFRHSVVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRNSFEVRVCACPGRDRRTEEENLRKKGEPHHELPPGSTKRALPNNTSSSPQPKKKPLDGEYFTLQIRGRERFEMFRELNEALELKDAQAGKEPGGSRAHSSHLKSKKGQSTSRHKKLMFKTEGPDSD'; const SAMPLE_B = '>p53_mouse\nMEEPQSDPSIEPPLSQETFSDLWKLLPENNVLSPLPSQAVDDLMLSPDDLAQWFTEDPGPDEAPRMSEAAPPAAPAPAAPTPAAPAPAPSWPLSSFVPSQKTYQGNYGFHLGFLQSGTAKSVMCTYSPPLNKLFCQLAKTCPVQLWVSATPPAGSRVRAMAIYKKSQHMTEVVRRCPHHERCSDSSDGLAPPQHLIRVEGNLRAEYLDDRNTFRHSIVVPYEPPEVGSDCTTIHYNYMCNSSCMGGMNRRPILTIITLEDSSGNLLGRDSFEVRVCACPGRDRRTEEENFKKKEPCPEPPPGSTRALGSTSTSSPTPKKKPLDGEYFTLKIRGRERFEMFRELNEALELKDAHATEEPFGGSRAHSSHLKSKKGQSTSRHKKFKKTADPSS'; function cleanLength(seq: string): number { return seq.replace(/[^A-Za-z]/g, '').length; } /** Boundary points of line y = x - offset clipped to the [0,W]x[0,H] rect. */ function diagSegment(offset: number, W: number, H: number): Array<[number, number]> { const pts: Array<[number, number]> = []; if (offset >= 0 && offset <= W) pts.push([offset, 0]); const xTop = H + offset; if (xTop >= 0 && xTop <= W) pts.push([xTop, H]); const yLeft = -offset; if (yLeft >= 0 && yLeft <= H) pts.push([0, yLeft]); const yRight = W - offset; if (yRight >= 0 && yRight <= H) pts.push([W, yRight]); return pts.slice(0, 2); } /** Boundary points of anti-diagonal line x + y = sum clipped to the rect. */ function antiSegment(sum: number, W: number, H: number): Array<[number, number]> { const pts: Array<[number, number]> = []; if (sum >= 0 && sum <= W) pts.push([sum, 0]); const xTop = sum - H; if (xTop >= 0 && xTop <= W) pts.push([xTop, H]); if (sum >= 0 && sum <= H) pts.push([0, sum]); const yRight = sum - W; if (yRight >= 0 && yRight <= H) pts.push([W, yRight]); return pts.slice(0, 2); } const tooltipStyle = { contentStyle: { background: 'var(--chart-tooltip-bg)', border: '1px solid var(--chart-tooltip-border)', borderRadius: 8, fontSize: 12 }, labelStyle: { color: 'var(--chart-tooltip-label)' }, itemStyle: { color: 'var(--chart-tooltip-item)' }, }; function FeatureChip({ label, value, tone = 'text-text-primary' }: { label: string; value: string; tone?: string }) { return (
{label} {value}
); } export default function DotPlotPage() { const [seqA, setSeqA] = useState(''); const [seqB, setSeqB] = useState(''); const [selfCompare, setSelfCompare] = useState(false); const [window, setWindow] = useState(10); const [stringency, setStringency] = useState(80); const [scoring, setScoring] = useState('identity'); const [loading, setLoading] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); const [accA, setAccA] = useState(''); const [accB, setAccB] = useState(''); const [accLoading, setAccLoading] = useState<'A' | 'B' | null>(null); const [accError, setAccError] = useState(null); const audit = useAuditTrail(); // Deep-link support: /analyze/dotplot?seq_a=...&seq_b=...&self=1&scoring=... useEffect(() => { const params = new URLSearchParams(globalThis.location?.search ?? ''); const a = params.get('seq_a'); const b = params.get('seq_b'); const score = params.get('scoring'); const w = params.get('window'); const s = params.get('stringency'); if (a) setSeqA(decodeURIComponent(a)); if (b) setSeqB(decodeURIComponent(b)); if (params.get('self') === '1') setSelfCompare(true); if (score && SCORING_OPTIONS.includes(score)) setScoring(score); if (w && Number(w) >= 1 && Number(w) <= 50) setWindow(Number(w)); if (s && Number(s) >= 40 && Number(s) <= 100) setStringency(Number(s)); }, []); const effectiveB = selfCompare ? seqA : seqB; const canRun = cleanLength(seqA) >= 1 && cleanLength(effectiveB) >= 1 && !loading; const data = useMemo( () => (result ? result.dots.map(([y, x], i) => ({ x, y, idx: i })) : []), [result], ); const handleAccessionSearch = async (target: 'A' | 'B') => { const acc = (target === 'A' ? accA : accB).trim(); if (!acc) return; setAccLoading(target); setAccError(null); try { const res = await fetchSequence(acc, 'uniprot'); if (target === 'A') { setSeqA(res.sequence); setAccA(''); } else { setSeqB(res.sequence); setAccB(''); } if (selfCompare) setSelfCompare(false); } catch (err: unknown) { setAccError(extractErrorMessage(err, `Could not fetch ${acc}`)); } finally { setAccLoading(null); } }; const handleRun = async () => { if (!canRun) return; setLoading(true); setError(null); setResult(null); const summary = `lenA:${cleanLength(seqA)},lenB:${cleanLength(effectiveB)},w:${window},s:${stringency},scoring:${scoring},self:${selfCompare}`; audit.emitStarted('dot_plot', 'Dot Plot', summary); try { const res = await runDotPlot({ seq_a: seqA, seq_b: effectiveB, window, stringency, scoring, }); setResult(res); audit.emitSuccess('dot_plot', 'Dot Plot', summary, `dots:${res.dot_count}`); } catch (err: unknown) { const errMsg = extractErrorMessage(err, 'Dot plot failed'); setError(errMsg); audit.emitFailed('dot_plot', 'Dot Plot', summary, errMsg); } finally { setLoading(false); } }; const toggleSelf = (value: boolean) => { setSelfCompare(value); if (value) setSeqB(seqA); }; const exportPng = () => { if (!result) return; const W = 1000; const H = Math.max(400, Math.round((result.seq_a_length / result.seq_b_length) * W)); const canvas = document.createElement('canvas'); canvas.width = W; canvas.height = H; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.fillStyle = '#0B0C14'; ctx.fillRect(0, 0, W, H); const sx = W / result.seq_b_length; const sy = H / result.seq_a_length; ctx.fillStyle = 'rgba(34,211,238,0.55)'; for (const [y, x] of result.dots) { ctx.fillRect(x * sx, H - (y + 1) * sy, Math.max(1, sx), Math.max(1, sy)); } const a = document.createElement('a'); a.download = `dotplot-${result.seq_a_length}x${result.seq_b_length}.png`; a.href = canvas.toDataURL('image/png'); a.click(); }; const exportSvg = () => { if (!result) return; const W = result.seq_b_length; const H = result.seq_a_length; const dots = result.dots.slice(0, 20000) .map(([y, x]) => ``) .join(''); const svg = `` + `${dots}`; downloadText(svg, `dotplot-${result.seq_a_length}x${result.seq_b_length}.svg`); }; const features: DotPlotFeatures | null = result?.features ?? null; const W = result?.seq_b_length ?? 0; const H = result?.seq_a_length ?? 0; const diagEnd = Math.min(W, H); const accessionInput = (target: 'A' | 'B') => (
{ if (target === 'A') setAccA(e.target.value); else setAccB(e.target.value); setAccError(null); }} onKeyDown={e => e.key === 'Enter' && handleAccessionSearch(target)} placeholder="or UniProt accession" className="input-flat text-xs flex-1" />
); return (

Sequence A (vertical axis)

setSeqA(e.target.value)} placeholder="Paste first sequence (raw or FASTA)..." className="w-full h-24 text-sm" /> {accessionInput('A')}

Sequence B (horizontal axis)

{!selfCompare && ( )}
setSeqB(e.target.value)} disabled={selfCompare} placeholder="Paste second sequence (raw or FASTA)..." className="w-full h-24 text-sm" /> {!selfCompare && accessionInput('B')} {accError &&

{accError}

}

Matrices require both inputs to be protein; otherwise scoring falls back to identity.

{loading ? : null} {loading ? 'Computing...' : 'Compute dot plot'}
{error && (
Dot plot failed: {error}
)} {result && !loading && (

Dot plot

{result.dot_count.toLocaleString()} dots A: {result.seq_a_length} · B: {result.seq_b_length} {result.sequence_type_a}{result.sequence_type_b && result.sequence_type_a !== result.sequence_type_b ? ` × ${result.sequence_type_b}` : ''} {result.scoring_used}{result.scoring !== result.scoring_used ? ' (requested ' + result.scoring + ')' : ''} · window {result.window} · ≥{result.stringency}% match
{result.downsampled && (

Downsampled: {result.total_matches.toLocaleString()} raw matches, showing a uniform subset.

)} {features && (
= 70 ? 'text-accent-purple' : 'text-accent-amber'} /> 0 ? 'text-accent-amber' : 'text-text-primary'} />
main diagonal repeat / duplication inverted repeat
)}
{features?.off_diagonal.map(f => { const seg = diagSegment(f.offset, W, H); if (seg.length < 2) return null; return ( ); })} {features?.anti_diagonal.map(f => { const seg = antiSegment(f.sum, W, H); if (seg.length < 2) return null; return ( ); })}
)}
); }