Skip to content

Commit

Permalink
feat: add on mouse hover/leave to MoleculeRepresentation
Browse files Browse the repository at this point in the history
  • Loading branch information
RamziWeslati committed Mar 19, 2024
1 parent b938001 commit 3098e4c
Show file tree
Hide file tree
Showing 5 changed files with 152 additions and 35 deletions.
76 changes: 59 additions & 17 deletions src/components/MoleculeRepresentation/MoleculeRepresentation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
isIdClickedAnAtom,
getAtomIdxFromClickableId,
CLICKABLE_MOLECULE_CLASSNAME,
ClickedBondIdentifiers,
BondIdentifiers,
IconCoords,
AttachedSvgIcon,
computeIconsCoords,
Expand All @@ -62,6 +62,8 @@ export const MoleculeRepresentation: React.FC<MoleculeRepresentationProps> = mem
id,
onAtomClick,
onBondClick,
onMouseHover,
onMouseLeave,
smarts,
smiles,
alignmentDetails,
Expand All @@ -81,14 +83,16 @@ export const MoleculeRepresentation: React.FC<MoleculeRepresentationProps> = mem
const [bondsHitbox, setBondsHitbox] = useState<SVGPathElement[]>([]);
const [iconsCoords, setIconsCoords] = useState<IconCoords[]>([]);
const isClickable = useMemo(() => !!onAtomClick || !!onBondClick, [onAtomClick, onBondClick]);
const isHoverable = useMemo(() => !!onMouseHover || !onMouseLeave, [onMouseHover, onMouseLeave]);
const isClickableOrHoverable = useMemo(() => isClickable || isHoverable, [isClickable, isHoverable]);
const [shouldComputeRectsDetails, setShouldComputeRectsDetails] = useState<{
shouldComputeRects: boolean;
computedRectsForAtoms: number[];
}>({ shouldComputeRects: false, computedRectsForAtoms: [] });

const computeClickingRects = useCallback(async () => {
if (!worker) return;
if (!isClickable) return;
if (!isClickableOrHoverable) return;
const structureToDraw = smiles || (smarts as string);
const moleculeDetails = await getMoleculeDetails(worker, { smiles: structureToDraw });
if (!moleculeDetails) return;
Expand All @@ -97,16 +101,17 @@ export const MoleculeRepresentation: React.FC<MoleculeRepresentationProps> = mem
() => {
// Check if component is mounted before updating state
if (moleculeRef?.current != null) {
if (onAtomClick) {
buildAtomsHitboxes({
numAtoms: moleculeDetails.numAtoms,
parentDiv: moleculeRef.current,
clickableAtoms: clickableAtoms?.clickableAtomsIds,
}).then(setAtomsHitbox);
}
if (onBondClick) {
buildBondsHitboxes(moleculeDetails.numAtoms, moleculeRef.current).then(setBondsHitbox);
}
buildAtomsHitboxes({
numAtoms: moleculeDetails.numAtoms,
parentDiv: moleculeRef.current,
clickableAtoms: clickableAtoms?.clickableAtomsIds,
isClickable: !!onAtomClick,
}).then(setAtomsHitbox);
buildBondsHitboxes({
numAtoms: moleculeDetails.numAtoms,
parentDiv: moleculeRef.current,
isClickable: !!onBondClick,
}).then(setBondsHitbox);
}
},
100,
Expand All @@ -118,7 +123,7 @@ export const MoleculeRepresentation: React.FC<MoleculeRepresentationProps> = mem
return () => {
clearTimeout(timeout);
};
}, [worker, isClickable, smiles, smarts, clickableAtoms?.clickableAtomsIds, onAtomClick, onBondClick]);
}, [worker, onAtomClick, onBondClick, isClickableOrHoverable, smiles, smarts, clickableAtoms?.clickableAtomsIds]);

useEffect(() => {
if (!shouldComputeRectsDetails.shouldComputeRects) return;
Expand Down Expand Up @@ -166,9 +171,11 @@ export const MoleculeRepresentation: React.FC<MoleculeRepresentationProps> = mem
}
}
setShouldComputeRectsDetails((prev) => {
const shouldInitClickableRects = isClickable && !prev.computedRectsForAtoms.length;
const shouldInitClickableRects = isClickableOrHoverable && !prev.computedRectsForAtoms.length;
const areClickableRectsOutOfDate =
isClickable && clickableAtoms && !isEqual(prev.computedRectsForAtoms, clickableAtoms?.clickableAtomsIds);
isClickableOrHoverable &&
clickableAtoms &&
!isEqual(prev.computedRectsForAtoms, clickableAtoms?.clickableAtomsIds);
if (shouldInitClickableRects || areClickableRectsOutOfDate) {
return { ...prev, shouldComputeRects: true };
}
Expand All @@ -193,6 +200,7 @@ export const MoleculeRepresentation: React.FC<MoleculeRepresentationProps> = mem
clickableAtoms,
alignmentDetails,
iconsCoords,
isClickableOrHoverable,
]);

const handleOnClick = useCallback(
Expand All @@ -211,6 +219,33 @@ export const MoleculeRepresentation: React.FC<MoleculeRepresentationProps> = mem
},
[onAtomClick, onBondClick, isClickable],
);
const handleOnMouseHover = useCallback(
(event: React.MouseEvent) => {
if (!onMouseHover) {
return;
}
const hoveredElementId = (event.target as SVGRectElement).id;
onMouseHover(
{
atomId: isIdClickedAnAtom(hoveredElementId) ? getAtomIdxFromClickableId(hoveredElementId) : undefined,
bondIdentifiers: isIdClickedABond(hoveredElementId)
? getClickedBondIdentifiersFromId(hoveredElementId)
: undefined,
},
event,
);
},
[onMouseHover],
);
const handleOnMouseLeave = useCallback(
(event: React.MouseEvent) => {
if (!onMouseLeave) {
return;
}
onMouseLeave(event);
},
[onMouseLeave],
);

if (!svgContent) {
if (showLoadingSpinner) return <Spinner width={width} height={height} />;
Expand All @@ -224,10 +259,12 @@ export const MoleculeRepresentation: React.FC<MoleculeRepresentationProps> = mem
setIsMoleculeRefSet(true);
},
...restOfProps,
className: `molecule ${isClickable ? CLICKABLE_MOLECULE_CLASSNAME : ''}`,
className: `molecule ${isClickableOrHoverable ? CLICKABLE_MOLECULE_CLASSNAME : ''}`,
height,
id,
onClick: handleOnClick,
onMouseOver: handleOnMouseHover,
onMouseLeave: handleOnMouseLeave,
style,
title: smiles,
width,
Expand Down Expand Up @@ -257,7 +294,12 @@ interface MoleculeRepresentationBaseProps {
height: number;
id?: string;
onAtomClick?: (atomId: string, event: React.MouseEvent) => void;
onBondClick?: (clickedBondIdentifiers: ClickedBondIdentifiers, event: React.MouseEvent) => void;
onBondClick?: (clickedBondIdentifiers: BondIdentifiers, event: React.MouseEvent) => void;
onMouseHover?: (
{ atomId, bondIdentifiers }: { atomId?: string; bondIdentifiers?: BondIdentifiers },
event: React.MouseEvent,
) => void;
onMouseLeave?: (event: React.MouseEvent) => void;
style?: CSSProperties;
showLoadingSpinner?: boolean;
showSmartsAsSmiles?: boolean;
Expand Down
61 changes: 56 additions & 5 deletions src/stories/MoleculeRepresentation.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { RDKitColor, RDKitProvider } from '@iktos-oss/rdkit-provider';
import { BIG_MOLECULE, MOLECULES, RANOLAZINE_SMILES, SEVEN_HIGHLIGHTS_RANOLAZINE } from './fixtures/molecules';
import { CCO_MOL_BLOCK, SMILES_TO_ALIGN_CCO_AGAINST } from './fixtures/molblock';
import { RDKitProviderProps } from '@iktos-oss/rdkit-provider';
import { ClickedBondIdentifiers } from '../utils';
import { BondIdentifiers } from '../utils';
import Popup from './fixtures/Popup';

export default {
Expand Down Expand Up @@ -121,7 +121,7 @@ const TemplateWithOnAtomClick: Story<MoleculeRepresentationProps> = (args) => {
const TemplateWithOnBondClick: Story<MoleculeRepresentationProps> = (args) => {
const [_, updateArgs] = useArgs();

const onBondClick = (identifiers: ClickedBondIdentifiers) => {
const onBondClick = (identifiers: BondIdentifiers) => {
const clickedBondId = parseInt(identifiers.bondId);
if (args.bondsToHighlight?.flat().includes(clickedBondId)) {
updateArgs({
Expand Down Expand Up @@ -153,8 +153,11 @@ const TemplateWithOnBondClick: Story<MoleculeRepresentationProps> = (args) => {

const TemplateWithOnAtomAndBondClick: Story<MoleculeRepresentationProps> = (args) => {
const [_, updateArgs] = useArgs();
const [hoveredAtomId, setHoveredAtomId] = useState<number | null>(null);
const [hoveredBondId, setHoveredBondId] = useState<number | null>(null);

const onAtomClick = (atomId: string) => {
setHoveredAtomId(null);
const clickedAtomId = parseInt(atomId);
if (args.atomsToHighlight?.flat().includes(clickedAtomId)) {
updateArgs({
Expand All @@ -177,7 +180,8 @@ const TemplateWithOnAtomAndBondClick: Story<MoleculeRepresentationProps> = (args
});
}
};
const onBondClick = (identifiers: ClickedBondIdentifiers) => {
const onBondClick = (identifiers: BondIdentifiers) => {
setHoveredBondId(null);
const clickedBondId = parseInt(identifiers.bondId);
if (args.bondsToHighlight?.flat().includes(clickedBondId)) {
updateArgs({
Expand All @@ -200,9 +204,56 @@ const TemplateWithOnAtomAndBondClick: Story<MoleculeRepresentationProps> = (args
});
}
};
const onMouseHover = (
{
atomId,
bondIdentifiers,
}: {
atomId?: string;
bondIdentifiers?: BondIdentifiers;
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
event: React.MouseEvent,
) => {
if (!atomId && !bondIdentifiers) {
setHoveredAtomId(null);
setHoveredBondId(null);
}
if (atomId) {
setHoveredBondId(null);
setHoveredAtomId(parseInt(atomId));
}
if (bondIdentifiers) {
setHoveredAtomId(null);
setHoveredBondId(parseInt(bondIdentifiers.bondId));
}
};
const onMouseLeave = () => {
setHoveredAtomId(null);
setHoveredBondId(null);
};

const isHoveredAtomInClickedAtoms =
hoveredAtomId !== null && args.atomsToHighlight?.some((atomHighlight) => atomHighlight.includes(hoveredAtomId));
const isHoveredBondInClickedAtoms =
hoveredBondId !== null && args.bondsToHighlight?.some((bondHighlight) => bondHighlight.includes(hoveredBondId));
return (
<RDKitProvider {...RDKitProviderCachingProps}>
<MoleculeRepresentation {...args} onAtomClick={onAtomClick} onBondClick={onBondClick} />
<MoleculeRepresentation
{...args}
atomsToHighlight={[
...(args.atomsToHighlight ? args.atomsToHighlight : [[]]),
hoveredAtomId !== null && !isHoveredAtomInClickedAtoms ? [hoveredAtomId] : [],
]}
bondsToHighlight={[
...(args.bondsToHighlight ? args.bondsToHighlight : [[]]),
hoveredBondId !== null && !isHoveredBondInClickedAtoms ? [hoveredBondId] : [],
]}
onAtomClick={onAtomClick}
onBondClick={onBondClick}
onMouseHover={onMouseHover}
onMouseLeave={onMouseLeave}
/>
</RDKitProvider>
);
};
Expand All @@ -211,7 +262,7 @@ const TemplateWithOnBondAndOnAtomClickAndPopup: Story<MoleculeRepresentationProp
// or use event.target as anchor instead of position
const [popup, setPopup] = useState({ show: false, content: <></>, position: { x: 0, y: 0 } });

const onBondClick = (identifiers: ClickedBondIdentifiers, event: React.MouseEvent) => {
const onBondClick = (identifiers: BondIdentifiers, event: React.MouseEvent) => {
const clickedBondId = parseInt(identifiers.bondId);
const rect = (event.target as HTMLElement).getBoundingClientRect();

Expand Down
25 changes: 18 additions & 7 deletions src/utils/dom-computation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ interface IconsPlacements {
yTranslate: number;
}

export interface ClickedBondIdentifiers {
export interface BondIdentifiers {
bondId: string;
startAtomId: string;
endAtomId: string;
Expand All @@ -77,10 +77,12 @@ export const buildAtomsHitboxes = async ({
numAtoms,
parentDiv,
clickableAtoms,
isClickable,
}: {
numAtoms: number;
parentDiv: SVGElement | null;
clickableAtoms?: number[];
isClickable: boolean;
}) => {
if (!parentDiv) return [];

Expand All @@ -95,7 +97,7 @@ export const buildAtomsHitboxes = async ({
const rectsForVisibleAtoms = await computeClickingVisibleAtomsHitboxCoords(numAtoms, parentDiv, atomsToIgnore);

const hitboxesCoords = [...rectsForVisibleAtoms, ...rectsForHiddenAtoms];
return hitboxesCoords.map((rect) => createHitboxRectFromCoords(rect));
return hitboxesCoords.map((rect) => createHitboxRectFromCoords({ coords: rect, isClickable }));
};

export const computeIconsCoords = async ({
Expand Down Expand Up @@ -170,7 +172,15 @@ export const computeIconsCoords = async ({
return coords;
};

export const buildBondsHitboxes = async (numAtoms: number, parentDiv: SVGElement | null): Promise<SVGPathElement[]> => {
export const buildBondsHitboxes = async ({
numAtoms,
parentDiv,
isClickable,
}: {
numAtoms: number;
parentDiv: SVGElement | null;
isClickable: boolean;
}): Promise<SVGPathElement[]> => {
if (!parentDiv) return [];
const clickablePaths: SVGPathElement[] = [];
for (let atomIdx = 0; atomIdx < numAtoms; atomIdx++) {
Expand All @@ -185,14 +195,15 @@ export const buildBondsHitboxes = async (numAtoms: number, parentDiv: SVGElement
if (isHighlightingPath(elem)) {
continue;
}
const hitboxPath = createHitboxPathFromPath(
elem,
getClickableBondId({
const hitboxPath = createHitboxPathFromPath({
path: elem,
id: getClickableBondId({
bondId: bondIndicies[0],
startAtomId: atomIndicesInBond[0],
endAtomId: atomIndicesInBond[1],
}),
);
isClickable,
});
clickablePaths.push(hitboxPath);
}
}
Expand Down
21 changes: 17 additions & 4 deletions src/utils/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,24 +65,37 @@ export const appendHitboxesToSvg = (svg: string, atomsHitboxes: SVGRectElement[]
return temp.innerHTML;
};

export const createHitboxRectFromCoords = (coords: Rect) => {
export const createHitboxRectFromCoords = ({ coords, isClickable }: { coords: Rect; isClickable: boolean }) => {
const rectElem = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rectElem.setAttribute('fill', 'transparent');
rectElem.setAttribute('x', coords.x.toString());
rectElem.setAttribute('y', coords.y.toString());
rectElem.setAttribute('width', coords.width.toString());
rectElem.setAttribute('height', coords.height.toString());
rectElem.id = coords.id;
rectElem.style.cursor = 'pointer';
if (isClickable) {
rectElem.style.cursor = 'pointer';
}

return rectElem;
};

export const createHitboxPathFromPath = (path: SVGPathElement, id: string) => {
export const createHitboxPathFromPath = ({
path,
id,
isClickable,
}: {
path: SVGPathElement;
id: string;
isClickable: boolean;
}) => {
const pathCopy = path.cloneNode(true) as SVGPathElement;
pathCopy.id = id;
pathCopy.style.stroke = 'transparent';
pathCopy.style.strokeWidth = '20px';
pathCopy.style.cursor = 'pointer';
if (isClickable) {
pathCopy.style.cursor = 'pointer';
}
return pathCopy;
};

Expand Down
4 changes: 2 additions & 2 deletions src/utils/identifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
SOFTWARE.
*/

import { ClickedBondIdentifiers } from './dom-computation';
import { BondIdentifiers } from './dom-computation';

export const CLICKABLE_MOLECULE_CLASSNAME = 'clickable-molecule';
const CLICKABLE_ATOM_ID = 'clickable-atom-';
Expand Down Expand Up @@ -51,7 +51,7 @@ export const getClickableBondId = ({
startAtomId: number;
endAtomId: number;
}) => `${CLICKABLE_BOND_ID}${bondId}:-atoms:${startAtomId}-${endAtomId}`;
export const getClickedBondIdentifiersFromId = (id: string): ClickedBondIdentifiers => {
export const getClickedBondIdentifiersFromId = (id: string): BondIdentifiers => {
const [bondId, atomsId] = id.replace(CLICKABLE_BOND_ID, '').replace('-atoms', '').split('::');
const [startAtomId, endAtomId] = atomsId.split('-');
return { bondId, startAtomId, endAtomId };
Expand Down

0 comments on commit 3098e4c

Please sign in to comment.