Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions gui/src/client/src/components/workspace/ClusterReadoutRows.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import React from "react";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import { horizontalScrollSx } from "../../theme/scrollbarSx";
import type { ClusterModule, ClusterReadout } from "../../features/clusters/types";

// Mirrors the PKS extender-unit classification in retromol_antismash/modules.py
// (PKSModule.substrate): that logic isn't serialized server-side, so it's
// recomputed here from the same anatomy flags for display purposes only.
function pksExtenderLabel(anatomy: Extract<ClusterModule, { type: "PKS" }>["anatomy"]): string {
const KR = anatomy.has_active_KR;
const DH = anatomy.has_active_DH;
const ER = anatomy.has_active_ER;
const effDH = DH && KR;
const effER = ER && KR && DH;

if (!KR) return "PKS_A";
if (!effDH) return "PKS_B";
if (!effER) return "PKS_C";
return "PKS_D";
}

function ModuleChip({ module }: { module: ClusterModule }) {
const isNRPS = module.type === "NRPS";
const substrateName = isNRPS ? module.predicted_substrate?.name ?? null : null;
const hasConfidentSubstrate = isNRPS && !!substrateName && substrateName !== "unknown";

const label = isNRPS
? (hasConfidentSubstrate ? substrateName! : "unknown substrate")
: pksExtenderLabel(module.anatomy);

const tooltipLines = [
`${module.type} module ${module.module_index_in_gene + 1} in ${module.gene_id} (${module.gene_strand} strand)`,
`domains: ${module.present_domains.join(", ") || "none"}`,
isNRPS && module.predicted_substrate?.score != null
? `prediction confidence: ${(module.predicted_substrate.score * 100).toFixed(1)}%`
: null,
].filter(Boolean).join("\n");

return (
<Tooltip title={<span style={{ whiteSpace: "pre-line" }}>{tooltipLines}</span>} arrow>
<Box
sx={{
px: 1.25,
py: 0.75,
borderRadius: 1,
border: "1px solid",
borderColor: isNRPS
? (hasConfidentSubstrate ? "success.main" : "divider")
: "info.main",
bgcolor: isNRPS
? (hasConfidentSubstrate ? "success.main" : "background.paper")
: "info.main",
color: isNRPS
? (hasConfidentSubstrate ? "success.contrastText" : "text.primary")
: "info.contrastText",
opacity: isNRPS && !hasConfidentSubstrate ? 1 : 0.92,
fontSize: "0.8rem",
whiteSpace: "nowrap",
flexShrink: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
lineHeight: 1.3,
minWidth: 72,
cursor: "default",
}}
>
<Typography variant="caption" sx={{ opacity: 0.75, fontSize: "0.65rem" }}>
{module.type} · {module.gene_id}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{label}
</Typography>
</Box>
</Tooltip>
);
}

// Read-only view of a gene cluster's parsed linear module readout(s) -- one row
// per antiSMASH region, each a horizontal chain of NRPS/PKS module chips. Unlike
// compounds, there's no "reconstruction" step here: the payload already IS the
// final module chain, so it's rendered directly rather than fetched separately.
export function ClusterReadoutRows({ readouts }: { readouts: ClusterReadout[] }) {
if (readouts.length === 0) {
return (
<Typography variant="body2" color="text.secondary">
No antiSMASH regions were found in this file.
</Typography>
);
}

return (
<Stack spacing={2}>
{readouts.map((readout, idx) => (
<Box key={`${readout.id}-${idx}`}>
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 600 }}>
{readout.id} ({readout.modules.length} module{readout.modules.length === 1 ? "" : "s"})
</Typography>

{readout.modules.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
No NRPS/PKS modules were detected in this region.
</Typography>
) : (
<Box sx={{ display: "flex", flexWrap: "nowrap", gap: 1, alignItems: "center", mt: 0.5, ...horizontalScrollSx }}>
{readout.modules.map((module, mIdx) => (
<ModuleChip key={`${readout.id}-module-${mIdx}`} module={module} />
))}
</Box>
)}
</Box>
))}
</Stack>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import MuiLink from "@mui/material/Link";
import Slider from "@mui/material/Slider";
import { DialogWindow } from "../DialogWindow";

// Matches ParasModel's own default (retromol_antismash.inference.model_paras).
const DEFAULT_PARAS_THRESHOLD = 0.1;

type DialogImportGeneClusterProps = {
open: boolean;
onClose: () => void;
onImport: (files: File[]) => void;
onImport: (files: File[], parasThreshold: number) => void;
}

export const DialogImportGeneCluster: React.FC<DialogImportGeneClusterProps> = ({
Expand All @@ -17,12 +21,16 @@ export const DialogImportGeneCluster: React.FC<DialogImportGeneClusterProps> = (
onImport,
}) => {
const [gbkFiles, setGbkFiles] = React.useState<File[]>([]);
const [parasThreshold, setParasThreshold] = React.useState<number>(DEFAULT_PARAS_THRESHOLD);
const canImport = gbkFiles.length > 0;

const reset = () => setGbkFiles([]);
const reset = () => {
setGbkFiles([]);
setParasThreshold(DEFAULT_PARAS_THRESHOLD);
};

const handleImport = () => {
onImport(gbkFiles);
onImport(gbkFiles, parasThreshold);
reset();
onClose();
}
Expand Down Expand Up @@ -62,6 +70,30 @@ export const DialogImportGeneCluster: React.FC<DialogImportGeneClusterProps> = (
{gbkFiles.length} file(s) selected
</Typography>
)}

<Stack spacing={0.5}>
<Typography variant="body2">
PARAS substrate confidence threshold: {parasThreshold.toFixed(2)}
</Typography>
<Typography variant="caption" color="text.secondary">
Minimum prediction probability required to call a substrate for an NRPS module. Lower surfaces more (lower-confidence) predictions; higher keeps only the most confident ones. Doesn't affect PKS modules, which are classified directly from domain anatomy.
</Typography>
<Slider
value={parasThreshold}
onChange={(_, value) => setParasThreshold(value as number)}
min={0}
max={1}
step={0.01}
marks={[
{ value: 0, label: "0" },
{ value: DEFAULT_PARAS_THRESHOLD, label: "default" },
{ value: 1, label: "1" },
]}
valueLabelDisplay="auto"
valueLabelFormat={(value) => value.toFixed(2)}
sx={{ maxWidth: 360 }}
/>
</Stack>
</Stack>
</DialogWindow>
)
Expand Down
40 changes: 36 additions & 4 deletions gui/src/client/src/components/workspace/DialogViewItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import Typography from "@mui/material/Typography";
import { useQuery } from "@tanstack/react-query";
import { Session, SessionItem } from "../../features/session/types";
import { reconstructCompound } from "../../features/reconstruction/api";
import { getClusterReadout } from "../../features/clusters/api";
import { DialogWindow } from "../DialogWindow";
import { ErrorBoundary } from "../ErrorBoundary";
import SmilesDrawerContainer from "../SmilesDrawerContainer.js";
import { PrimarySequenceRows, usePrimarySequenceEditor } from "./PrimarySequenceEditor";
import { ClusterReadoutRows } from "./ClusterReadoutRows";

type HighlightAtom = [number, string];

Expand Down Expand Up @@ -122,6 +124,12 @@ export const DialogViewItem: React.FC<DialogViewItemProps> = ({
? (reconstructionQuery.error as Error).message || "Unknown error"
: null;

const clusterReadoutQuery = useQuery({
queryKey: ["getClusterReadout", sessionId, item.id],
queryFn: ({ signal }) => getClusterReadout(sessionId, item.id, signal),
enabled: open && !isCompound && item.status === "done",
});

// resetSignal is `open` -- re-seeding on every open (even for the same item)
// matches the dialog's existing "fresh state each time" behavior for selectedTags.
const editor = usePrimarySequenceEditor(session, setSession, item, data, open);
Expand Down Expand Up @@ -165,10 +173,34 @@ export const DialogViewItem: React.FC<DialogViewItemProps> = ({
]}
maxWidth={"lg"}
>
{!isCompound && (
<Alert severity="info" sx={{ mb: 2 }}>
Viewing is only available for compounds.
</Alert>
{!isCompound && item.kind === "cluster" && (
<>
{item.status === "queued" && (
<Typography variant="body2" color="text.secondary">
Waiting to be parsed...
</Typography>
)}

{item.status === "processing" && <CircularProgress size={24} />}

{item.status === "error" && (
<Alert severity="error">
{item.errorMessage || "Parsing failed."}
</Alert>
)}

{item.status === "done" && clusterReadoutQuery.isLoading && <CircularProgress size={24} />}

{item.status === "done" && clusterReadoutQuery.error && (
<Alert severity="error">
{(clusterReadoutQuery.error as Error).message || "Failed to load parsed gene cluster."}
</Alert>
)}

{item.status === "done" && clusterReadoutQuery.data && (
<ClusterReadoutRows readouts={clusterReadoutQuery.data.readouts} />
)}
</>
)}

{loading && (
Expand Down
Loading
Loading