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
6 changes: 6 additions & 0 deletions gui/src/client/src/components/MenuContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import BarChartIcon from "@mui/icons-material/BarChart";
import HomeRoundedIcon from "@mui/icons-material/HomeRounded";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import RuleIcon from "@mui/icons-material/Rule";
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
import { useNavigate, useLocation } from "react-router-dom";

const mainListItems = [
Expand All @@ -33,6 +34,11 @@ const mainListItems = [
icon: <BarChartIcon />,
to: `/dashboard/enrichment`
},
{
text: "Generate",
icon: <AutoFixHighIcon />,
to: `/dashboard/generate`
},
{
text: "Rules",
icon: <RuleIcon />,
Expand Down
53 changes: 49 additions & 4 deletions gui/src/client/src/components/SmilesDrawerContainer.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ import { useColorScheme } from '@mui/material/styles';


class CustomSvgDrawer extends SmilesDrawer.SvgDrawer {
constructor(options, showIsotopes = false) {
constructor(options, showIsotopes = false, orientationTags = null) {
super(options);
this.showIsotopes = showIsotopes;
// { startTags, endTags }: isotope tags (see retromol.chem.tagging) of the
// first and last blocks in a generated sequence -- when given, the drawing
// is mirrored horizontally (if needed) so the start block ends up on the
// left, matching reading order of the primary sequence shown alongside it.
this.orientationTags = orientationTags;

const themeOverrides = {
light: {
Expand Down Expand Up @@ -86,8 +91,35 @@ class CustomSvgDrawer extends SmilesDrawer.SvgDrawer {
atom.bracket = null;
}

orientSequenceLeftToRight(startTags, endTags) {
const graph = this.preprocessor?.graph;
if (!graph || !startTags?.length || !endTags?.length) return;

const vertices = graph.vertices.filter((v) => v.position);
if (!vertices.length) return;

const avgX = (tags) => {
const matched = vertices.filter((v) => v.value?.bracket && tags.includes(v.value.bracket.isotope));
if (!matched.length) return null;
return matched.reduce((sum, v) => sum + v.position.x, 0) / matched.length;
};

const startX = avgX(startTags);
const endX = avgX(endTags);
if (startX === null || endX === null || startX <= endX) return;

const xs = vertices.map((v) => v.position.x);
const centerX = (Math.min(...xs) + Math.max(...xs)) / 2;
for (const vertex of vertices) {
vertex.position.x = 2 * centerX - vertex.position.x;
}
}

drawAtomHighlights(highlights) {
this.prepareTinAsRightWildcard('Sn');
if (this.orientationTags) {
this.orientSequenceLeftToRight(this.orientationTags.startTags, this.orientationTags.endTags);
}

let preprocessor = this.preprocessor;
let graph = preprocessor.graph;
Expand Down Expand Up @@ -177,12 +209,17 @@ class CustomSvgDrawer extends SmilesDrawer.SvgDrawer {
* @property {string} [themeOverride] force “light” or “dark” drawing theme
* @property {boolean} [showIsotopes] draw isotope numbers (e.g. R-group
* tags like [1*]/[2*]) instead of hiding them
* @property {{startTags: number[], endTags: number[]}} [orientationTags]
* isotope tags of the first/last blocks in a
* generated sequence -- mirrors the drawing
* horizontally (if needed) so the start block
* ends up on the left
*/

/**
* @param {Props} props
*/
const SmilesDrawerContainer = ({ identifier, smiles, size, highlightAtoms = [], themeOverride = '', showIsotopes = false }) => {
const SmilesDrawerContainer = ({ identifier, smiles, size, highlightAtoms = [], themeOverride = '', showIsotopes = false, orientationTags = null }) => {
const { mode, systemMode } = useColorScheme();
const [error, setError] = useState(null);

Expand All @@ -198,7 +235,15 @@ const SmilesDrawerContainer = ({ identifier, smiles, size, highlightAtoms = [],

// A fresh drawer instance per draw call, since it isn't safe to reuse
// once it holds a graph for a previous (possibly differently-sized) SMILES.
let drawer = new CustomSvgDrawer({ width: size, height: size }, showIsotopes);
//
// padding is bumped above the library default (10) because that default
// only leaves room for atom labels -- it doesn't account for the extra
// radius a highlight circle (customDrawAtomHighlight, r = bondLength / 3)
// draws around a highlighted atom, so a highlighted atom sitting at the
// very edge of the layout (as the first/last block often does once
// orientationTags pins it there) gets its highlight clipped by the SVG's
// own viewBox.
let drawer = new CustomSvgDrawer({ width: size, height: size, padding: 24 }, showIsotopes, orientationTags);

try {
SmilesDrawer.parse(
Expand All @@ -221,7 +266,7 @@ const SmilesDrawerContainer = ({ identifier, smiles, size, highlightAtoms = [],
console.error('SmilesDrawerContainer: unexpected error', err);
setError('Could not render this structure.');
}
}, [identifier, smiles, highlightAtoms, size, themeOverride, showIsotopes, mode, systemMode]);
}, [identifier, smiles, highlightAtoms, size, themeOverride, showIsotopes, orientationTags, mode, systemMode]);

if (error) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export function usePrimarySequenceEditor(

export type PrimarySequenceEditorState = ReturnType<typeof usePrimarySequenceEditor>;

function PrimarySequenceChips({
export function PrimarySequenceChips({
sequence,
selectedTags,
onToggleMotif,
Expand Down
2 changes: 2 additions & 0 deletions gui/src/client/src/components/workspace/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { WorkspaceHome } from "./WorkspaceHome";
import { WorkspaceUpload } from "./WorkspaceUpload";
import { WorkspaceDiscovery } from "./WorkspaceDiscovery";
import { WorkspaceRules } from "./WorkspaceRules";
import { WorkspaceGenerate } from "./WorkspaceGenerate";
// import { WorkspaceEnrichment } from "./tabs/enrichment/WorkspaceEnrichment";

export const Workspace: React.FC = () => {
Expand Down Expand Up @@ -173,6 +174,7 @@ export const Workspace: React.FC = () => {
<Route path="discovery" element={<WorkspaceDiscovery session={session} setSession={setSession} />} />
{/*<Route path="enrichment" element={<WorkspaceEnrichment session={session} setSession={setSession} />} />*/}
<Route path="enrichment" element={<div>Analysis currently available. Check back later.</div>} />
<Route path="generate" element={<WorkspaceGenerate />} />
<Route path="rules" element={<WorkspaceRules />} />
</Routes>
</Box>
Expand Down
194 changes: 194 additions & 0 deletions gui/src/client/src/components/workspace/WorkspaceGenerate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import React from "react";
import Box from "@mui/material/Box";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Alert from "@mui/material/Alert";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import { generateBackbone } from "../../features/reconstruction/api";
import type { GeneratedBackbone } from "../../features/reconstruction/types";
import { useNotifications } from "../NotificationProvider";
import { ErrorBoundary } from "../ErrorBoundary";
import { ExportImageButton } from "../ExportImageButton";
import SmilesDrawerContainer from "../SmilesDrawerContainer.js";
import { DrawingAttribution } from "../DrawingAttribution";
import { SequenceEditor, type SequenceBlock } from "./SequenceEditor";
import { PrimarySequenceChips } from "./PrimarySequenceEditor";

type HighlightAtom = [number, string];

export const WorkspaceGenerate: React.FC = () => {
const { pushNotification } = useNotifications();

const [blocks, setBlocks] = React.useState<SequenceBlock[]>([]);
const [generating, setGenerating] = React.useState(false);
const [result, setResult] = React.useState<GeneratedBackbone | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [selectedTags, setSelectedTags] = React.useState<number[]>([]);
const diagramRef = React.useRef<HTMLDivElement>(null);

const canGenerate = blocks.length > 0 && !generating;

const handleGenerate = async () => {
if (!canGenerate) return;
setGenerating(true);
setError(null);
setSelectedTags([]);

try {
const reconstruction = await generateBackbone(blocks.map((b) => b.name));
setResult(reconstruction);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setResult(null);
setError(msg);
pushNotification(`Failed to generate backbone: ${msg}`, "error");
} finally {
setGenerating(false);
}
};

const handleToggleMotif = (tags: number[]) => {
setSelectedTags((prev) => {
const allSelected = tags.every((tag) => prev.includes(tag));
if (allSelected) return prev.filter((tag) => !tags.includes(tag));
return Array.from(new Set([...prev, ...tags]));
});
};

const handleCopySmiles = async () => {
if (!result?.backboneSmiles) return;
try {
await navigator.clipboard.writeText(result.backboneSmiles);
pushNotification("Copied SMILES to clipboard", "success");
} catch {
pushNotification("Failed to copy SMILES", "error");
}
};

// Memoized so it's a stable reference across re-renders that don't touch
// selectedTags -- otherwise SmilesDrawerContainer redraws on every unrelated
// parent re-render (same reasoning as DialogViewItem's highlightAtoms).
const highlightAtoms = React.useMemo<HighlightAtom[]>(
() => selectedTags.map((tag) => [tag, "#027bf3"]),
[selectedTags]
);

const orientationTags = React.useMemo(() => {
if (!result || result.primary_sequence.length < 2) return undefined;
const [, startTags] = result.primary_sequence[0];
const [, endTags] = result.primary_sequence[result.primary_sequence.length - 1];
if (!startTags.length || !endTags.length) return undefined;
return { startTags, endTags };
}, [result]);

return (
<Box sx={{ width: "100%", mx: "auto", display: "flex", flexDirection: "column", gap: "16px" }}>
<Card variant="outlined">
<CardContent>
<Typography component="h1" variant="subtitle1">
Build a primary sequence
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Add building blocks in biosynthetic order, then generate the linear backbone
structure RetroMol's fusion chemistry would assemble from them.
</Typography>

{blocks.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
No blocks yet.
</Typography>
) : null}

<SequenceEditor blocks={blocks} onChange={setBlocks} disabled={generating} />

<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mt: 2 }}>
<Button variant="contained" disabled={!canGenerate} onClick={handleGenerate}>
Generate
</Button>
{generating && <CircularProgress size={20} />}
</Stack>
</CardContent>
</Card>

{(result || error) && (
<Card variant="outlined">
<CardContent>
<Typography component="h1" variant="subtitle1">
Generated backbone
</Typography>

{error && <Alert severity="error" sx={{ mt: 1 }}>{error}</Alert>}

{result && (
<Box sx={{ mt: 1.5 }}>
{result.backbone_warning && (
<Alert severity="warning" sx={{ mb: 1.5 }}>
{result.backbone_warning}
</Alert>
)}

{result.tagged_backbone_smiles ? (
<>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Stack direction="column" spacing={0} alignItems="flex-start">
<ExportImageButton
targetRef={diagramRef}
filename={`retromol-generated-${blocks.map((b) => b.name).join("-").replace(/[^a-z0-9]+/gi, "-")}`}
label="Download the diagram and sequence below as a PNG"
/>
{result.backboneSmiles && (
<Button
size="small"
variant="text"
startIcon={<ContentCopyIcon fontSize="small" />}
onClick={handleCopySmiles}
>
Copy SMILES
</Button>
)}
</Stack>
</Box>
<Box ref={diagramRef} sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 0.5, mb: 1.5 }}>
<ErrorBoundary
what="molecule structure"
fallback={
<Typography variant="caption" color="text.secondary">
Could not render this structure.
</Typography>
}
>
<SmilesDrawerContainer
identifier="generated-backbone"
smiles={result.tagged_backbone_smiles}
size={300}
highlightAtoms={highlightAtoms}
orientationTags={orientationTags}
/>
</ErrorBoundary>
<DrawingAttribution library="smiles-drawer" />
<Box sx={{ display: "flex", justifyContent: "center", width: "100%", mt: 2 }}>
<PrimarySequenceChips
sequence={result.primary_sequence}
selectedTags={selectedTags}
onToggleMotif={handleToggleMotif}
/>
</Box>
</Box>
</>
) : (
<Box sx={{ display: "flex", justifyContent: "center" }}>
<PrimarySequenceChips sequence={result.primary_sequence} selectedTags={[]} />
</Box>
)}
</Box>
)}
</CardContent>
</Card>
)}
</Box>
);
};
10 changes: 9 additions & 1 deletion gui/src/client/src/features/reconstruction/api.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { postJson } from "../http";
import { saveSession } from "../session/api";
import type { Session } from "../session/types";
import { ReconstructCompoundRespSchema, Reconstruction, PrimarySequenceItem } from "./types";
import { ReconstructCompoundRespSchema, GenerateBackboneRespSchema, GeneratedBackbone, Reconstruction, PrimarySequenceItem } from "./types";

// Generates a linear backbone from a hand-typed primary sequence (block names,
// e.g. from a from-scratch SequenceEditor) rather than an already-parsed
// compound -- see routes/rules.py's generate_backbone / reconstruct_named_sequence.
export async function generateBackbone(sequence: string[], signal?: AbortSignal): Promise<GeneratedBackbone> {
const data = await postJson("/api/generateBackbone", { sequence }, GenerateBackboneRespSchema, signal);
return data.data;
}

export interface ReconstructCompoundResult {
reconstructions: Reconstruction[];
Expand Down
13 changes: 13 additions & 0 deletions gui/src/client/src/features/reconstruction/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ export const ReconstructionSchema = z.object({
});
export type Reconstruction = z.output<typeof ReconstructionSchema>;

export const GeneratedBackboneSchema = ReconstructionSchema.extend({
// Same structure as tagged_backbone_smiles but with RetroMol's internal atom
// tags stripped -- what a "copy SMILES" affordance should hand the user,
// rather than leaking isotope tags into a SMILES they'd paste elsewhere. Null
// under the same conditions tagged_backbone_smiles is.
backboneSmiles: z.string().nullable().default(null),
});
export type GeneratedBackbone = z.output<typeof GeneratedBackboneSchema>;

export const GenerateBackboneRespSchema = z.object({
data: GeneratedBackboneSchema,
});

export const ReconstructCompoundRespSchema = z.object({
ok: z.boolean().optional(),
status: z.string().optional(),
Expand Down
3 changes: 2 additions & 1 deletion gui/src/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
get_discovery_context,
)
from routes.rate_limit import limiter, RATE_LIMIT_REJECTIONS
from routes.rules import blp_rule_set
from routes.rules import blp_rule_set, blp_generate_backbone


# Initialize the Flask app
Expand Down Expand Up @@ -254,6 +254,7 @@ def ready() -> tuple[dict[str, str], int]:
app.register_blueprint(blp_submit_discovery_query)
app.register_blueprint(blp_get_discovery_query_result)
app.register_blueprint(blp_rule_set)
app.register_blueprint(blp_generate_backbone)

# The two rate-limit tiers on top of the app-wide default (see routes/rate_limit.py)
# are applied as @limiter.limit(...) decorators directly on each route function, in
Expand Down
Loading
Loading