Skip to content

Commit

Permalink
feat(viewers): add SDF file viewer
Browse files Browse the repository at this point in the history
  • Loading branch information
OliverDudgeon committed Sep 18, 2023
1 parent 64bb0df commit 2e3e282
Show file tree
Hide file tree
Showing 17 changed files with 1,038 additions and 189 deletions.
61 changes: 61 additions & 0 deletions components/MolCard/CalculationsTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Table, TableBody, TableCell, TableRow, Tooltip, Typography } from "@mui/material";

export interface CalculationsTableProps {
properties: { name: string; value: string | undefined }[];
tableWidth?: number;
calcs?: { [key: string]: string };
blacklist?: string[];
fontSize?: number | string;
}

const CalculationsTable = ({
calcs,
blacklist = [],
properties,
fontSize,
tableWidth,
}: CalculationsTableProps) => {
return (
<Table>
<TableBody>
{properties
.filter((property) => !blacklist.includes(property.name))
.map(({ name, value = "" }, index) => {
// TODO: round number-like value to 2 sf
const displayName = calcs?.[name] ?? name;
return (
<TableRow key={index}>
<TableCell
component="th"
style={{ maxWidth: tableWidth ? `calc(${tableWidth - 32}px - 35px)` : "5rem" }}
sx={{
fontSize,
paddingY: 0.5,
paddingX: 0,
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
<Tooltip arrow title={displayName}>
<span>{displayName}</span>
</Tooltip>
</TableCell>
<TableCell
align="left"
sx={{ fontSize, paddingY: 0.5, paddingX: 0, width: "100%", pl: 1 }}
>
<Tooltip arrow title={value}>
<Typography noWrap sx={{ fontSize, width: 32, minWidth: "100%" }}>
{value}
</Typography>
</Tooltip>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
};

export default CalculationsTable;
65 changes: 65 additions & 0 deletions components/MolCard/DepictMolecule.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { styled } from "@mui/styles";

export interface DepictMoleculeProps {
smiles: string;
width?: number;
height?: number;
margin?: number;
expand?: boolean;
background?: string;
colorScheme?: string;
explicitHOnly?: boolean;
highlightAtoms?: number[];
highlightColor?: string;
outerGlow?: boolean;
mcs?: string;
mcsColor?: string;
noStereo?: boolean;
alt?: string;
fragnet_server?: string;
depict_route?: string;
}

export const DepictMolecule = (props: DepictMoleculeProps) => {
const {
smiles,
width,
height,
margin = 0,
expand = true,
noStereo = false,
mcs = "",
mcsColor = "0xFFAAAAAA",
fragnet_server = "https://squonk.informaticsmatters.org",
depict_route = "/fragnet-depict-api/fragnet-depict/moldepict",
} = props;

const params = {
mol: smiles,
m: String(margin),
expand: String(expand),
mcs: String(mcs),
noStereo: String(noStereo),
mcsColor,
};
const searchParams = Object.keys(params).map(
(key) => `${key}=${encodeURIComponent(params[key as keyof typeof params])}`,
);

return (
<Image
alt={smiles || "invalid smiles"}
height={height}
loading="lazy"
src={smiles && `${fragnet_server}${depict_route}?${searchParams.join("&")}`}
width={width}
/>
);
};

const Image = styled("img")({
overflow: "hidden",
display: "inline-block",
maxWidth: "100%",
height: "auto",
});
60 changes: 60 additions & 0 deletions components/MolCard/MolCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Children, useState } from "react";

import type { CardActionsProps, CardProps } from "@mui/material";
import { Card, CardActions, CardContent } from "@mui/material";

import { DepictMolecule } from "./DepictMolecule";

export interface MolCardProps extends CardProps {
smiles: string;
children?: React.ReactNode;
depictNoStereo?: boolean;
depictWidth?: number;
depictHeight?: number;
depictmcs?: string;
bgColor?: string;
actions?: (hover?: boolean) => React.ReactNode;
actionsProps?: CardActionsProps;
onClick?: () => void;
}

/* Generic card rendering molecule depiction with optional content and actions. */
export const MolCard = ({
children,
smiles,
depictNoStereo = false,
depictWidth,
depictHeight,
depictmcs,
bgColor,
actions = () => undefined,
actionsProps,
onClick,
...cardProps
}: MolCardProps) => {
const [hover, setHover] = useState<boolean>(false);

return (
<Card
{...cardProps}
sx={{ bgcolor: bgColor, cursor: onClick ? "pointer" : undefined }}
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
<CardContent>
<DepictMolecule
height={depictHeight}
mcs={depictmcs}
noStereo={depictNoStereo}
smiles={smiles}
width={depictWidth}
/>
{Children.only(children)}
</CardContent>
<CardActions {...actionsProps} disableSpacing>
{actions(hover)}
</CardActions>
</Card>
);
};
1 change: 1 addition & 0 deletions components/MolCard/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./MolCard";
197 changes: 197 additions & 0 deletions components/ScatterPlot/ScatterPlot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import type { PlotParams } from "react-plotly.js";

import {
Box,
CircularProgress,
MenuItem,
Switch,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import dynamic from "next/dynamic";
import type { PlotDatum } from "plotly.js-basic-dist";

import type { Molecule } from "../../features/SDFViewer";

const Plot = dynamic<PlotParams>(
() => import("../../components/viz/Plot").then((mod) => mod.Plot),
{
ssr: false, // Plotly only works when browser APIs are in scope
loading: () => <CircularProgress size="1rem" />,
},
);

// Utils

export const isNumber = (x: unknown): x is number => typeof x === "number";

const getPropArrayFromMolecules = (molecules: Molecule[], prop: string | null) => {
if (prop === "id") {
return molecules.map((molecule) => molecule.id);
}
return molecules.map((molecule) => (prop ? molecule.properties[prop] ?? null : null));
};

type AxisSeries = ReturnType<typeof getPropArrayFromMolecules>;

const scaleToSize = (sizeaxis: AxisSeries) => {
const sx = sizeaxis
.filter((value): value is string => value !== null)
.map((value) => Number.parseFloat(value));
const min = Math.min(...sx);
const max = Math.max(...sx);

const scaledSizes = sx.map((v) => (45 * (v - min)) / max + 5);

if (min >= max) {
return { sizes: 10 };
}

return { sizes: scaledSizes, min, max };
};

const validateColours = (colouraxis: AxisSeries) => {
const cx = colouraxis
.filter((value): value is string => value !== null)
.map((value) => Number.parseFloat(value));

const min = Math.min(...cx);
const max = Math.max(...cx);

if (min >= max) {
return { colours: 1 };
}

return { colours: colouraxis, min, max };
};

export interface ScatterPlotProps {
molecules: Molecule[];
properties: string[];
selectPoints: (ids: string[]) => void;
width: number;
}

export const ScatterPlot = ({ molecules, properties, selectPoints, width }: ScatterPlotProps) => {
// const selectedPoints = selection.map((id) => molecules.findIndex((m) => m.id === id));

const [showColourBar, setShowColourBar] = useState(true);

const [xprop, setXprop] = useState(properties[0]);
const [yprop, setYprop] = useState(properties[1]);
const [cprop, setCprop] = useState(properties[2]);
const [sprop, setSprop] = useState(properties[3]);

const xaxis = getPropArrayFromMolecules(molecules, xprop);
const yaxis = getPropArrayFromMolecules(molecules, yprop);

const colouraxis = getPropArrayFromMolecules(molecules, cprop);
const sizeaxis = getPropArrayFromMolecules(molecules, sprop);

const { sizes, ...sizeExtent } = scaleToSize(sizeaxis);
const { colours } = validateColours(colouraxis);

return (
<>
<Plot
config={{
modeBarButtonsToRemove: [
"resetScale2d",
"hoverClosestCartesian",
"hoverCompareCartesian",
"toImage",
"toggleSpikelines",
],
}}
data={[
{
x: xaxis,
y: yaxis,
customdata: molecules.map((m) => m.id), // Add custom data for use in selection
// selectedpoints: selectedPoints.length ? selectedPoints : undefined, // null or undefined?
type: "scatter",
mode: "markers",
marker: {
color: colours,
size: sizes,
colorscale: "Bluered",
colorbar: showColourBar ? {} : undefined,
},
},
]}
layout={{
width,
height: width,
margin: { t: 10, r: 10, b: 50, l: 50 },
dragmode: "select",
selectionrevision: 1,
hovermode: "closest",
xaxis: { title: xprop },
yaxis: { title: yprop },
}}
onDeselect={() => selectPoints([])}
onSelected={(event) => {
// @types is wrong here, we need `?.` as points can be undefined (double click event)
const points = event.points as PlotDatum[] | undefined;
points?.length && selectPoints(points.map((p) => p.customdata) as string[]);
}}
/>
<Box display="flex" gap={2}>
<div>
<Typography gutterBottom component="label" display="block" variant="h5">
x-axis
</Typography>
<AxisSelector prop={xprop} properties={properties} onPropChange={setXprop} />
</div>
<div>
<Typography gutterBottom component="label" display="block" variant="h5">
y-axis
</Typography>
<AxisSelector prop={yprop} properties={properties} onPropChange={setYprop} />
</div>
<div>
<Typography gutterBottom component="label" display="block" variant="h5">
colour-axis
</Typography>
<AxisSelector prop={cprop} properties={properties} onPropChange={setCprop} />
<Tooltip arrow title="Toggle the colour bar">
<Switch checked={showColourBar} onChange={() => setShowColourBar(!showColourBar)} />
</Tooltip>
{/* <div>
<em>({colourExtent.min !== undefined && `${colourExtent.min}–${colourExtent.max}`})</em>
</div> */}
</div>
<div>
<Typography gutterBottom component="label" display="block" variant="h5">
size-axis
</Typography>
<AxisSelector prop={sprop} properties={properties} onPropChange={setSprop} />
<div>
<em>({sizeExtent.min !== undefined && `${sizeExtent.min}${sizeExtent.max}`})</em>
</div>
</div>
</Box>
</>
);
};

interface AxisSelectorProps {
properties: string[];
prop: string;
onPropChange: Dispatch<SetStateAction<string>>;
}

const AxisSelector = ({ properties, prop = "", onPropChange }: AxisSelectorProps) => {
return (
<TextField select value={prop} onChange={(event) => onPropChange(event.target.value)}>
{properties.map((property) => (
<MenuItem key={property} value={property}>
{property}
</MenuItem>
))}
</TextField>
);
};
Loading

0 comments on commit 2e3e282

Please sign in to comment.