Skip to content

Commit e220c00

Browse files
authored
Attribution for drawers (#24)
1 parent 01c9b94 commit e220c00

11 files changed

Lines changed: 119 additions & 20 deletions

File tree

.github/workflows/gui-tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ jobs:
3131
- name: Lint
3232
run: npx eslint src --ext .ts,.tsx,.js
3333

34+
- name: Check SmilesDrawer version attribution
35+
run: npm run check:smiles-drawer-version
36+
3437
- name: Test
3538
run: npm test -- --watchAll=false
3639
env:

gui/src/client/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
"scripts": {
3939
"start": "craco start",
4040
"build": "craco build",
41-
"test": "craco test"
41+
"test": "craco test",
42+
"check:smiles-drawer-version": "node scripts/checkSmilesDrawerVersion.js"
4243
},
4344
"eslintConfig": {
4445
"extends": [
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#!/usr/bin/env node
2+
// Fails if DrawingAttribution.tsx's hardcoded SMILES_DRAWER_VERSION drifts from the
3+
// version npm actually resolved in package-lock.json. Needed because smiles-drawer's
4+
// package.json is blocked from import (its own "exports" field), so the "Drawn with
5+
// SmilesDrawer vX" caption can't read the version at runtime the way ReactionScheme
6+
// does for RDKit -- see DrawingAttribution.tsx for that comparison.
7+
const fs = require("fs");
8+
const path = require("path");
9+
10+
const root = path.join(__dirname, "..");
11+
12+
const lockfile = JSON.parse(fs.readFileSync(path.join(root, "package-lock.json"), "utf8"));
13+
const resolvedVersion = lockfile.packages?.["node_modules/smiles-drawer"]?.version;
14+
15+
if (!resolvedVersion) {
16+
console.error("checkSmilesDrawerVersion: could not find node_modules/smiles-drawer in package-lock.json");
17+
process.exit(1);
18+
}
19+
20+
const attributionPath = path.join(root, "src/components/DrawingAttribution.tsx");
21+
const attributionSource = fs.readFileSync(attributionPath, "utf8");
22+
const match = attributionSource.match(/SMILES_DRAWER_VERSION\s*=\s*"([^"]+)"/);
23+
24+
if (!match) {
25+
console.error(`checkSmilesDrawerVersion: could not find SMILES_DRAWER_VERSION in ${attributionPath}`);
26+
process.exit(1);
27+
}
28+
29+
const hardcodedVersion = match[1];
30+
31+
if (hardcodedVersion !== resolvedVersion) {
32+
console.error(
33+
`checkSmilesDrawerVersion: DrawingAttribution.tsx says smiles-drawer v${hardcodedVersion}, ` +
34+
`but package-lock.json resolves it to v${resolvedVersion}. ` +
35+
`Update SMILES_DRAWER_VERSION in src/components/DrawingAttribution.tsx to match.`
36+
);
37+
process.exit(1);
38+
}
39+
40+
console.log(`checkSmilesDrawerVersion: OK (v${resolvedVersion})`);
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React from "react";
2+
import Typography from "@mui/material/Typography";
3+
import type { SxProps, Theme } from "@mui/material/styles";
4+
5+
// smiles-drawer's package.json is blocked from import by its own "exports" field, so
6+
// this is kept in sync by hand with the resolved version in package-lock.json --
7+
// scripts/checkSmilesDrawerVersion.js fails CI/lint if the two drift apart.
8+
const SMILES_DRAWER_VERSION = "2.4.1";
9+
10+
type DrawingAttributionProps =
11+
| { library: "smiles-drawer"; sx?: SxProps<Theme> }
12+
// RDKit drawings are produced server-side (see gui/src/server/routes/rules.py), so
13+
// there's no local constant to hardcode -- the version is only known once the
14+
// caller has actually fetched a drawing and the server reported what rendered it.
15+
| { library: "rdkit"; version: string; sx?: SxProps<Theme> };
16+
17+
// A single caption attributing one or more structure/reaction drawings above it to the
18+
// library that rendered them. Place once per diagram -- if a diagram contains multiple
19+
// drawings from the same library (e.g. a compound plus its reconstructions), attribute
20+
// the whole group once rather than repeating this per drawing.
21+
export const DrawingAttribution: React.FC<DrawingAttributionProps> = (props) => {
22+
const label = props.library === "smiles-drawer" ? `SmilesDrawer v${SMILES_DRAWER_VERSION}` : `RDKit v${props.version}`;
23+
return (
24+
<Typography
25+
variant="caption"
26+
color="text.secondary"
27+
sx={{ display: "block", fontStyle: "italic", ...props.sx }}
28+
>
29+
Drawn with {label}
30+
</Typography>
31+
);
32+
};

gui/src/client/src/components/MotifHoverCard.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { useQuery } from "@tanstack/react-query";
77
import { fetchMotifStructures } from "../features/motifs/api";
88
import { MotifName } from "./MotifName";
99
import SmilesDrawerContainer from "./SmilesDrawerContainer.js";
10+
import { DrawingAttribution } from "./DrawingAttribution";
1011

1112
const DRAWING_SIZE = 100;
1213

@@ -38,7 +39,10 @@ function MotifHoverContent({ name, hint }: { name: string; hint?: string }) {
3839
return (
3940
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 0.5, maxWidth: 200, py: 0.5 }}>
4041
{smiles ? (
41-
<SmilesDrawerContainer identifier={`motif-hover-${reactId}`} smiles={smiles} size={DRAWING_SIZE} />
42+
<>
43+
<SmilesDrawerContainer identifier={`motif-hover-${reactId}`} smiles={smiles} size={DRAWING_SIZE} />
44+
<DrawingAttribution library="smiles-drawer" sx={{ fontSize: "0.65rem" }} />
45+
</>
4246
) : (
4347
<Box
4448
sx={{

gui/src/client/src/components/workspace/DialogViewItem.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { DialogWindow } from "../DialogWindow";
1414
import { ErrorBoundary } from "../ErrorBoundary";
1515
import { ExportImageButton } from "../ExportImageButton";
1616
import SmilesDrawerContainer from "../SmilesDrawerContainer.js";
17+
import { DrawingAttribution } from "../DrawingAttribution";
1718
import { PrimarySequenceRows, usePrimarySequenceEditor } from "./PrimarySequenceEditor";
1819
import { ClusterReadoutRows } from "./ClusterReadoutRows";
1920

@@ -440,6 +441,7 @@ export const DialogViewItem: React.FC<DialogViewItemProps> = ({
440441
</>
441442
)}
442443
</Box>
444+
<DrawingAttribution library="smiles-drawer" sx={{ textAlign: "center" }} />
443445
{hasReconstructions && (
444446
<DescriptionBox
445447
title={'Explanation'}

gui/src/client/src/components/workspace/ReactionScheme.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import DOMPurify from "dompurify";
1616
import { useColorScheme } from "@mui/material/styles";
1717
import { useQuery } from "@tanstack/react-query";
1818
import { fetchReactionSchemeSvg } from "../../features/rules/api";
19+
import { DrawingAttribution } from "../DrawingAttribution";
1920

2021
export function ReactionScheme({ id, smarts }: { id: string; smarts: string }) {
2122
const { mode, systemMode } = useColorScheme();
@@ -31,7 +32,7 @@ export function ReactionScheme({ id, smarts }: { id: string; smarts: string }) {
3132
// Server-rendered SVG markup gets injected raw via dangerouslySetInnerHTML, so it
3233
// must be sanitized first -- same rationale/profile as SvgViewer.
3334
const sanitizedSvg = React.useMemo(
34-
() => (svgQuery.data ? DOMPurify.sanitize(svgQuery.data, { USE_PROFILES: { svg: true, svgFilters: true } }) : null),
35+
() => (svgQuery.data ? DOMPurify.sanitize(svgQuery.data.svg, { USE_PROFILES: { svg: true, svgFilters: true } }) : null),
3536
[svgQuery.data]
3637
);
3738

@@ -43,7 +44,7 @@ export function ReactionScheme({ id, smarts }: { id: string; smarts: string }) {
4344
);
4445
}
4546

46-
if (svgQuery.error || !sanitizedSvg) {
47+
if (svgQuery.error || !sanitizedSvg || !svgQuery.data) {
4748
return (
4849
<Alert severity="warning" variant="outlined" sx={{ py: 0 }}>
4950
Could not render this reaction ({smarts}).
@@ -52,9 +53,12 @@ export function ReactionScheme({ id, smarts }: { id: string; smarts: string }) {
5253
}
5354

5455
return (
55-
<Box
56-
sx={{ "& svg": { display: "block", maxWidth: "100%", height: "auto" } }}
57-
dangerouslySetInnerHTML={{ __html: sanitizedSvg }}
58-
/>
56+
<>
57+
<Box
58+
sx={{ "& svg": { display: "block", maxWidth: "100%", height: "auto" } }}
59+
dangerouslySetInnerHTML={{ __html: sanitizedSvg }}
60+
/>
61+
<DrawingAttribution library="rdkit" version={svgQuery.data.rdkitVersion} />
62+
</>
5963
);
6064
}

gui/src/client/src/components/workspace/WorkspaceRules.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { MinimalIconButton } from "../MinimalIconButton";
2222
import { MotifName } from "../MotifName";
2323
import { horizontalScrollSx } from "../../theme/scrollbarSx";
2424
import SmilesDrawerContainer from "../SmilesDrawerContainer.js";
25+
import { DrawingAttribution } from "../DrawingAttribution";
2526
import { ReactionScheme } from "./ReactionScheme";
2627

2728
const STRUCTURE_SIZE = 130;
@@ -95,11 +96,14 @@ function MatchingRuleRow({ rule }: { rule: MatchingRule }) {
9596

9697
<Collapse in={expanded} unmountOnExit>
9798
<Stack direction="row" spacing={2} sx={{ mt: 1, pl: 1 }}>
98-
<SmilesDrawerContainer
99-
identifier={`matching-rule-${rule.id}`}
100-
smiles={rule.displaySmiles || rule.smiles}
101-
size={STRUCTURE_SIZE}
102-
/>
99+
<Box>
100+
<SmilesDrawerContainer
101+
identifier={`matching-rule-${rule.id}`}
102+
smiles={rule.displaySmiles || rule.smiles}
103+
size={STRUCTURE_SIZE}
104+
/>
105+
<DrawingAttribution library="smiles-drawer" />
106+
</Box>
103107

104108
<Stack spacing={1} sx={{ flex: 1, minWidth: 0 }}>
105109
<Box>

gui/src/client/src/features/rules/api.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { getJson } from "../http";
2-
import { RuleSetRespSchema, type RuleSetResp } from "./types";
3-
import { ItemDrawingResultSchema } from "../drawing/types";
2+
import { RuleSetRespSchema, ReactionSchemeSvgRespSchema, type RuleSetResp, type ReactionSchemeSvgResp } from "./types";
43

54
// The whole default rule set, fetched once and cached by the caller (see
65
// WorkspaceRules) -- it's small (a few hundred rules total) and effectively static
@@ -15,11 +14,10 @@ export async function fetchReactionSchemeSvg(
1514
ruleId: string,
1615
theme: "light" | "dark",
1716
signal?: AbortSignal
18-
): Promise<string> {
19-
const data = await getJson(
17+
): Promise<ReactionSchemeSvgResp> {
18+
return getJson(
2019
`/api/reactionSchemeSvg/${encodeURIComponent(ruleId)}?theme=${theme}`,
21-
ItemDrawingResultSchema,
20+
ReactionSchemeSvgRespSchema,
2221
signal
2322
);
24-
return data.svg;
2523
}

gui/src/client/src/features/rules/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,13 @@ export const RuleSetRespSchema = z.object({
2929
reactionRules: z.array(ReactionRuleSchema),
3030
});
3131
export type RuleSetResp = z.output<typeof RuleSetRespSchema>;
32+
33+
export const ReactionSchemeSvgRespSchema = z.object({
34+
svg: z.string(),
35+
// The RDKit version that rendered `svg` -- reported by the server (see
36+
// routes/rules.py's reaction_scheme_svg) rather than pinned client-side, so the
37+
// "Drawn with RDKit vX" attribution in ReactionScheme can never drift from what
38+
// actually rendered it.
39+
rdkitVersion: z.string(),
40+
});
41+
export type ReactionSchemeSvgResp = z.output<typeof ReactionSchemeSvgRespSchema>;

0 commit comments

Comments
 (0)