diff --git a/package-lock.json b/package-lock.json index 6ad7e47..6b77611 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@floating-ui/react": "^0.27.16", "@fontsource/roboto": "^5.2.6", "@mui/icons-material": "^7.1.2", "@mui/material": "^7.1.2", @@ -24,6 +25,7 @@ "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dotenv": "^16.5.0", + "export-to-csv": "^1.4.0", "idb-keyval": "^6.2.2", "lodash": "^4.17.21", "papaparse": "^5.5.3", @@ -33,6 +35,7 @@ "react-hook-form": "^7.59.0", "react-json-view": "^1.21.3", "react-resizable-panels": "^3.0.6", + "rich-textarea": "^0.27.0", "shortid": "^2.2.17", "vite-tsconfig-paths": "^5.1.4", "yaml": "^2.8.1" @@ -1245,6 +1248,59 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.16", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.16.tgz", + "integrity": "sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.6", + "@floating-ui/utils": "^0.2.10", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@fontsource/roboto": { "version": "5.2.8", "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.8.tgz", @@ -4578,6 +4634,15 @@ "node": ">=0.10.0" } }, + "node_modules/export-to-csv": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/export-to-csv/-/export-to-csv-1.4.0.tgz", + "integrity": "sha512-6CX17Cu+rC2Fi2CyZ4CkgVG3hLl6BFsdAxfXiZkmDFIDY4mRx2y2spdeH6dqPHI9rP+AsHEfGeKz84Uuw7+Pmg==", + "license": "MIT", + "engines": { + "node": "^v12.20.0 || >=v14.13.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6159,6 +6224,15 @@ "node": ">=0.10.0" } }, + "node_modules/rich-textarea": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/rich-textarea/-/rich-textarea-0.27.0.tgz", + "integrity": "sha512-u3vTbomrZtItGVLiOWJ4wbQq5IeY2jtykN543shn7NuIzjUqxucZHhG3HksMZKgaexRAYh0XZ6fmtUvfl0sHEA==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.14.0" + } + }, "node_modules/robust-predicates": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", @@ -6472,6 +6546,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tabbable": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", + "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", diff --git a/package.json b/package.json index 5c5553f..f947d06 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@floating-ui/react": "^0.27.16", "@fontsource/roboto": "^5.2.6", "@mui/icons-material": "^7.1.2", "@mui/material": "^7.1.2", @@ -26,6 +27,7 @@ "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dotenv": "^16.5.0", + "export-to-csv": "^1.4.0", "idb-keyval": "^6.2.2", "lodash": "^4.17.21", "papaparse": "^5.5.3", @@ -35,6 +37,7 @@ "react-hook-form": "^7.59.0", "react-json-view": "^1.21.3", "react-resizable-panels": "^3.0.6", + "rich-textarea": "^0.27.0", "shortid": "^2.2.17", "vite-tsconfig-paths": "^5.1.4", "yaml": "^2.8.1" diff --git a/src/context/biolink.ts b/src/context/biolink.ts index 131624e..8032415 100644 --- a/src/context/biolink.ts +++ b/src/context/biolink.ts @@ -3,6 +3,13 @@ import React from "react"; interface BiolinkContextType { colorMap?: (categories: string | string[]) => [string | null, string]; hierarchies?: Record; + concepts?: string[]; + predicates?: { + domain: string; + predicate: string; + range: string; + symmetric: boolean; + }[] } const BiolinkContext = React.createContext({}); diff --git a/src/pages/explore/Explore.tsx b/src/pages/explore/Explore.tsx index 283de39..777c956 100644 --- a/src/pages/explore/Explore.tsx +++ b/src/pages/explore/Explore.tsx @@ -16,7 +16,7 @@ export default function Explore() { - + Enrichment Analysis void; + inputNodeTaxa: string; + inputNodeType: string; + curieMode: boolean; +}) { + const [open, setOpen] = useState(false); + const [value, setValue] = useState(''); + const [activeIndex, setActiveIndex] = useState(null); + const [validNames, setValidNames] = useState>(new Map()); + + useEffect(() => { + let curies = []; + if (curieMode) { + curies = value.split('\n').map((line) => line.trim()).filter((line) => line.length > 0); + } else { + curies = value + .split('\n') + .map((line) => validNames.get(line)) + .filter((line) => line !== undefined); + } + onCurieListChange(curies); + }, [value, validNames, onCurieListChange, curieMode]); + + const [selection, setSelection] = useState({ + top: 0, left: 0, selectionStart: 0, selectionEnd: 0, + }); + + const listRef = useRef>([]); + + const { refs, floatingStyles, context } = useFloating({ + whileElementsMounted: autoUpdate, + open, + onOpenChange: setOpen, + placement: 'bottom-start', + middleware: [ + flip(), + size({ + padding: 16, + apply({ availableHeight, availableWidth, elements }) { + elements.floating.style.maxHeight = `${availableHeight}px`; + elements.floating.style.maxWidth = `${availableWidth}px`; + }, + }), + ], + }); + + const role = useRole(context, { role: 'listbox' }); + const dismiss = useDismiss(context); + const listNav = useListNavigation(context, { + listRef, + activeIndex, + onNavigate: setActiveIndex, + openOnArrowKeyDown: false, + virtual: true, + loop: true, + enabled: open && !curieMode, + }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions( + [role, dismiss, listNav], + ); + + const getCurrentLineIndex = useCallback( + () => value.slice(0, selection.selectionStart).split('\n').length - 1, + [value, selection], + ); + + useEffect(() => { + refs.setPositionReference({ + getBoundingClientRect: () => ({ + x: 0, + y: 0, + top: selection.top, + left: selection.left, + bottom: selection.top + 20, + right: selection.left, + width: 0, + height: 20, + }), + }); + }, [selection, refs]); + + const currentLineText = useMemo(() => { + const currentLineIndex = getCurrentLineIndex(); + return value.split('\n')[currentLineIndex]; + }, [getCurrentLineIndex, value]); + + const { + data: options, + isLoading, + } = useQuery<{ + curie: string; + label: string; + synonyms: string[]; + taxa: string[]; + types: string[]; + }[]>({ + queryFn: async ({ signal }) => { + if (curieMode) return []; + if (currentLineText.length === 0) return []; + return nameLookup({ + signal, + name: currentLineText, + taxaFilter: inputNodeTaxa.trim().split(',').map(((t) => t.trim())), + biolinkTypeFilter: inputNodeType, + }); + }, + queryKey: [currentLineText, inputNodeTaxa, inputNodeType], + }); + + function handleSelectItem() { + setActiveIndex(null); + setOpen(false); + + if (!options || options.length === 0 || activeIndex === null) return; + + const selectedOption = options[activeIndex]; + + setValidNames((prev) => { + const nextMap = new Map(prev); + nextMap.set(selectedOption.label, selectedOption.curie); + return nextMap; + }); + + setValue((prev) => { + const lines = prev.split('\n'); + const currentLineIndex = getCurrentLineIndex(); + lines[currentLineIndex] = selectedOption.label; + return lines.join('\n'); + }); + } + + return ( +
+ {/* REFERENCE */} + + Input nodes (1 per line) + + { + setValue((e.target as unknown as { value: string }).value); + }, + 'aria-autocomplete': 'list', + onKeyDown: (e) => { + if (open) { + if (e.key === 'Enter') { + e.preventDefault(); + handleSelectItem(); + } + } + + const noModifiers = !e.ctrlKey && !e.altKey && !e.metaKey; + + if ( + (e.ctrlKey && e.code === 'Space') || + (noModifiers && e.key.length === 1 && e.key.match(/\S| /)) + ) { + setActiveIndex(0); + setOpen(true); + } + }, + })} + > + {(content) => content.split('\n').map((line, i) => { + let style; + if (!curieMode) { + if (validNames.has(line)) { + style = { backgroundColor: '#95FA7F' }; + } else { + style = { backgroundColor: '#F09C97' }; + } + } + return ( + + + {`${line}\n`} + + + ); + })} + + + {/* FLOATING */} + {open && !curieMode && ( + + +
+ {isLoading && ( +
+ Loading… +
+ )} + {options === undefined || (options.length === 0 && !isLoading) ? ( +
+ No matching results, please try a different query +
+ ) : ( + <> + {Boolean(options) && options.map((option, i) => ( + +
+ {option.label} + + {option.curie} + +
+
+ ))} + + )} +
+
+
+ )} +
+ ); +} + +type ItemProps = React.HTMLAttributes & { + active?: boolean; + children: React.ReactNode; +}; + +const Item = React.memo( + forwardRef( + ({ children, active, ...rest }, ref) => { + const id = useId(); + return ( +
+ {children} +
+ ); + }, + ), +); diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 0a66a77..ae45c74 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -28,6 +28,7 @@ import { Route as ExploreIndexRouteImport } from './routes/explore/index' import { Route as DetailsIndexRouteImport } from './routes/details/index' import { Route as AnswerIndexRouteImport } from './routes/answer/index' import { Route as ActivateUserIndexRouteImport } from './routes/activate-user/index' +import { Route as ExploreEnrichmentAnalysisRouteImport } from './routes/explore/enrichment-analysis' import { Route as ShareShare_idIndexRouteImport } from './routes/share/$share_id/index' import { Route as ExploreGraphsIndexRouteImport } from './routes/explore/graphs/index' import { Route as ExploreDrugChemicalIndexRouteImport } from './routes/explore/drug-chemical/index' @@ -130,6 +131,12 @@ const ActivateUserIndexRoute = ActivateUserIndexRouteImport.update({ path: '/activate-user/', getParentRoute: () => rootRouteImport, } as any) +const ExploreEnrichmentAnalysisRoute = + ExploreEnrichmentAnalysisRouteImport.update({ + id: '/explore/enrichment-analysis', + path: '/explore/enrichment-analysis', + getParentRoute: () => rootRouteImport, + } as any) const ShareShare_idIndexRoute = ShareShare_idIndexRouteImport.update({ id: '/share/$share_id/', path: '/share/$share_id/', @@ -178,6 +185,7 @@ export interface FileRoutesByFullPath { '/termsofservice': typeof TermsofserviceRoute '/tutorial': typeof TutorialRoute '/welcome': typeof WelcomeRoute + '/explore/enrichment-analysis': typeof ExploreEnrichmentAnalysisRoute '/activate-user': typeof ActivateUserIndexRoute '/answer': typeof AnswerIndexRoute '/details': typeof DetailsIndexRoute @@ -205,6 +213,7 @@ export interface FileRoutesByTo { '/termsofservice': typeof TermsofserviceRoute '/tutorial': typeof TutorialRoute '/welcome': typeof WelcomeRoute + '/explore/enrichment-analysis': typeof ExploreEnrichmentAnalysisRoute '/activate-user': typeof ActivateUserIndexRoute '/answer': typeof AnswerIndexRoute '/details': typeof DetailsIndexRoute @@ -233,6 +242,7 @@ export interface FileRoutesById { '/termsofservice': typeof TermsofserviceRoute '/tutorial': typeof TutorialRoute '/welcome': typeof WelcomeRoute + '/explore/enrichment-analysis': typeof ExploreEnrichmentAnalysisRoute '/activate-user/': typeof ActivateUserIndexRoute '/answer/': typeof AnswerIndexRoute '/details/': typeof DetailsIndexRoute @@ -262,6 +272,7 @@ export interface FileRouteTypes { | '/termsofservice' | '/tutorial' | '/welcome' + | '/explore/enrichment-analysis' | '/activate-user' | '/answer' | '/details' @@ -289,6 +300,7 @@ export interface FileRouteTypes { | '/termsofservice' | '/tutorial' | '/welcome' + | '/explore/enrichment-analysis' | '/activate-user' | '/answer' | '/details' @@ -316,6 +328,7 @@ export interface FileRouteTypes { | '/termsofservice' | '/tutorial' | '/welcome' + | '/explore/enrichment-analysis' | '/activate-user/' | '/answer/' | '/details/' @@ -344,6 +357,7 @@ export interface RootRouteChildren { TermsofserviceRoute: typeof TermsofserviceRoute TutorialRoute: typeof TutorialRoute WelcomeRoute: typeof WelcomeRoute + ExploreEnrichmentAnalysisRoute: typeof ExploreEnrichmentAnalysisRoute ActivateUserIndexRoute: typeof ActivateUserIndexRoute AnswerIndexRoute: typeof AnswerIndexRoute DetailsIndexRoute: typeof DetailsIndexRoute @@ -492,6 +506,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ActivateUserIndexRouteImport parentRoute: typeof rootRouteImport } + '/explore/enrichment-analysis': { + id: '/explore/enrichment-analysis' + path: '/explore/enrichment-analysis' + fullPath: '/explore/enrichment-analysis' + preLoaderRoute: typeof ExploreEnrichmentAnalysisRouteImport + parentRoute: typeof rootRouteImport + } '/share/$share_id/': { id: '/share/$share_id/' path: '/share/$share_id' @@ -552,6 +573,7 @@ const rootRouteChildren: RootRouteChildren = { TermsofserviceRoute: TermsofserviceRoute, TutorialRoute: TutorialRoute, WelcomeRoute: WelcomeRoute, + ExploreEnrichmentAnalysisRoute: ExploreEnrichmentAnalysisRoute, ActivateUserIndexRoute: ActivateUserIndexRoute, AnswerIndexRoute: AnswerIndexRoute, DetailsIndexRoute: DetailsIndexRoute, diff --git a/src/routes/explore/enrichment-analysis.tsx b/src/routes/explore/enrichment-analysis.tsx new file mode 100644 index 0000000..1fbd3bc --- /dev/null +++ b/src/routes/explore/enrichment-analysis.tsx @@ -0,0 +1,352 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import React, { useContext, useRef, useState } from "react"; +import axios from "axios"; +import NodeInputBox from "../../pages/explore/enrichment-analysis/NodeInputBox"; +import BiolinkContext from "../../context/biolink"; +import { Button, Container } from "@mui/material"; +import { generateCsv, mkConfig, download } from "export-to-csv"; + +export const Route = createFileRoute("/explore/enrichment-analysis")({ + component: RouteComponent, +}); + +function RouteComponent() { + return ; +} + +function generateEnrichmentQuery( + inputNodeType: string, + outputNodeType: string, + inputCuries: string[], + predicate: string, + inputIsSubject = false +) { + return { + message: { + query_graph: { + nodes: { + input: { + categories: [inputNodeType], + ids: ["uuid:1"], + member_ids: inputCuries, + set_interpretation: "MANY", + }, + output: { + categories: [outputNodeType], + }, + }, + edges: { + edge_0: { + subject: inputIsSubject ? "input" : "output", + object: inputIsSubject ? "output" : "input", + predicates: [predicate], + // knowledge_type: "inferred" + }, + }, + }, + }, + }; +} + +function extractResultsStructured(resp: any) { + const resultsArray = []; + + const results = resp.message.results || []; + const kgNodes = resp.message.knowledge_graph.nodes || {}; + const kgEdges = resp.message.knowledge_graph.edges || {}; + + for (const result of results) { + const nb = result.node_bindings.output[0].id; + const name = (Boolean(kgNodes[nb]) && kgNodes[nb].name) || "N/A"; + + const edgeId = result.analyses[0].edge_bindings.edge_0[0].id; + const edge = kgEdges[edgeId]; + let pValue = null; + + for (const att of edge.attributes || []) { + if (att.attribute_type_id === "biolink:p_value") { + pValue = att.value; + break; + } + } + + resultsArray.push({ + id: nb, + name, + p_value: pValue, + }); + } + + return resultsArray; +} + +function EnrichedQueries() { + const { concepts: categories, predicates } = useContext(BiolinkContext); + + const [curies, setCuries] = useState([]); + const [inputNodeType, setInputNodeType] = useState(""); + const [inputNodeTaxa, setInputNodeTaxa] = useState(""); + const [relationship, setRelationship] = useState("related_to"); + const [outputType, setOutputType] = useState("NamedThing"); + const [curieMode, setCurieMode] = useState(false); + + const abortControllerRef = useRef(null); + const [isLoading, setIsLoading] = useState(false); + const [results, setResults] = useState([]); + + function stopQuery() { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + setIsLoading(false); + } + setIsLoading(false); + } + + async function startQuery() { + const controller = new AbortController(); + abortControllerRef.current = controller; + + setIsLoading(true); + const query = generateEnrichmentQuery( + `biolink:${inputNodeType || "NamedThing"}`, + `biolink:${outputType}`, + curies, + `biolink:${relationship}` + ); + const { data } = await axios.post( + "https://answercoalesce.renci.org/query", + query, + { signal: controller.signal } + ); + setResults(extractResultsStructured(data)); + setIsLoading(false); + } + + if (!categories?.length || !predicates?.length) { + return Loading...; + } + + return ( + + ← View all tools +

Enrichment Analysis

+

+ This tool helps discover common connections between nodes. Given a list + of input nodes, a relationship, and an output type, it will return a + list of nodes that are best connected to the input nodes via the + relationship. +

+ +
+
+
+
+ setInputNodeTaxa(e.target.value)} + /> +
+
+ + + + + +
+ c.split(":")[1]).sort()} + onChange={setOutputType} + value={outputType} + /> +
+ +
+ + + {results.length > 0 && ( + + )} +
+ + {results.length > 0 && ( +
+

Results

+ + + + + + + + + + {results + .sort((a: { p_value: number }, b: { p_value: number }) => a.p_value - b.p_value) + .slice(0, 500) + .map(({ id, name, p_value }: { id: string, name: string, p_value: number}) => ( + + + + + + ))} + +
IDName + P-value +
{id}{name}{p_value.toFixed(6)}
+
+ )} +
+ +
+ ); +} + +function Select({ + options, + onChange, + value, + label, + notSelectedOption, +}: { + options: string[]; + onChange: React.Dispatch>; + value: string | undefined; + label: string; + notSelectedOption?: string; +}) { + return ( +
+ + {label} + + +
+ ); +}