'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 =
``;
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 (
);
})}
)}
);
}