diff --git a/src/components/search/concept-modal/concept-modal.css b/src/components/search/concept-modal/concept-modal.css index 62f82025..c429c2ed 100644 --- a/src/components/search/concept-modal/concept-modal.css +++ b/src/components/search/concept-modal/concept-modal.css @@ -58,6 +58,14 @@ margin-bottom: -16px; } +.explanation-score-progress { + display: inline-flex !important; + align-items: center; +} +.explanation-score-progress .ant-progress-outer { + display: inline-flex; +} + @media (min-width: 600px) { .tab-name { display: inline; diff --git a/src/components/search/concept-modal/concept-modal.js b/src/components/search/concept-modal/concept-modal.js index fd3ea6e5..5b58a108 100644 --- a/src/components/search/concept-modal/concept-modal.js +++ b/src/components/search/concept-modal/concept-modal.js @@ -9,9 +9,10 @@ import CustomIcon, { ExportOutlined as ExternalLinkIcon, FullscreenOutlined as FullscreenLayoutIcon, UnorderedListOutlined as CdesIcon, + QuestionCircleOutlined as ExplanationIcon, ArrowLeftOutlined, InfoCircleOutlined } from '@ant-design/icons' -import { CdesTab, OverviewTab, StudiesTab, KnowledgeGraphsTab, TranQLTab } from './tabs' +import { CdesTab, OverviewTab, StudiesTab, KnowledgeGraphsTab, TranQLTab, ExplanationTab } from './tabs' import { useHelxSearch } from '../' import { BouncingDots } from '../../' import { useAnalytics, useEnvironment } from '../../../contexts' @@ -92,16 +93,17 @@ export const ConceptModalBody = ({ result }) => { ) const cdeTitle = (
- CDEs { cdes ? `(${ Object.keys(cdes).length })` : } + CDEs { !cdesLoading ? `(${ Object.keys(cdes ?? []).length })` : }
) const tabs = { - 'overview': { title: 'Overview', icon: , content: , }, - 'studies': { title: studyTitle, icon: , content: , }, - 'cdes': { title: cdeTitle, icon: , content: }, - 'kgs': { title: 'Knowledge Graphs', icon: , content: , }, - 'tranql': { title: 'TranQL', icon: , content: } + 'overview': { title: 'Overview', icon: , content: , }, + 'studies': { title: studyTitle, icon: , content: , }, + 'cdes': { title: cdeTitle, icon: , content: }, + 'kgs': { title: 'Knowledge Graphs', icon: , content: , }, + 'explanation': { title: 'Explanation', icon: , content: }, + 'tranql': { title: 'TranQL', icon: , content: } } const links = { 'robokop' : { title: 'ROBOKOP', icon: , url: "https://robokop.renci.org/" } diff --git a/src/components/search/concept-modal/tabs/cdes/cde-item.js b/src/components/search/concept-modal/tabs/cdes/cde-item.js index fa4b2f7d..13d96cbe 100644 --- a/src/components/search/concept-modal/tabs/cdes/cde-item.js +++ b/src/components/search/concept-modal/tabs/cdes/cde-item.js @@ -26,7 +26,7 @@ export const CdeItem = ({ cde, cdeRelatedConcepts, highlight }) => { ), [cdeRelatedConcepts, cde]) const Highlighter = useCallback(({ ...props }) => ( - <_Highlighter searchWords={highlight} {...props}/> + <_Highlighter autoEscape={ true } searchWords={highlight} {...props}/> ), [highlight]) return ( diff --git a/src/components/search/concept-modal/tabs/cdes/related-concepts/related-concept-tag.js b/src/components/search/concept-modal/tabs/cdes/related-concepts/related-concept-tag.js index 4ad35687..75e16c68 100644 --- a/src/components/search/concept-modal/tabs/cdes/related-concepts/related-concept-tag.js +++ b/src/components/search/concept-modal/tabs/cdes/related-concepts/related-concept-tag.js @@ -38,7 +38,7 @@ export const RelatedConceptTag = ({ concept, highlight }) => { setShowOptions(true) }}> - + diff --git a/src/components/search/concept-modal/tabs/explanation.js b/src/components/search/concept-modal/tabs/explanation.js new file mode 100644 index 00000000..5f9bda09 --- /dev/null +++ b/src/components/search/concept-modal/tabs/explanation.js @@ -0,0 +1,250 @@ +import { useMemo, useRef, useState } from 'react' +import { Button, Checkbox, Divider, Progress, Space, Switch, Typography } from 'antd' +import { presetPalettes } from '@ant-design/colors' +import { Pie } from '@ant-design/plots' +import { InfoTooltip } from '../../..' + +const { Title, Text } = Typography + +// Show the first 6 score components, unless show more is pressed. +const SHOW_MORE_CUTOFF = 6 + +const palette = [ + presetPalettes.blue, + presetPalettes.gold, + presetPalettes.green, + presetPalettes.purple, + presetPalettes.volcano, + presetPalettes.cyan, + presetPalettes.magenta, + presetPalettes.yellow, + presetPalettes.red, + presetPalettes.lime, + presetPalettes.geekblue +].map((palette) => palette[5]) + +const parseScoreDetail = ({ value, description, details }) => { + if (value === 0) return null + if (description === "sum of:") { + return details.flatMap((detail) => parseScoreDetail(detail)) + } + + const explainPattern = /^weight\((?.+):(?.+) in (?\d+)\) \[(?.+)\], result of:$/ + const match = description.match(explainPattern) + if (match) { + let { fieldName, searchTerm, segmentNumber, similarityMetric } = match.groups + if (searchTerm.startsWith(`"`) && searchTerm.endsWith(`"`)) searchTerm = searchTerm.slice(1, -1) + return { + fieldMatch: fieldName, + termMatch: searchTerm, + source: description, + value + } + } else { + console.log("Failed to parse score explanation:", description) + return { + fieldMatch: null, + termMatch: null, + source: description, + value + } + } +} + +export const ExplanationTab = ({ result }) => { + const { explanation } = result + + const [showMore, setShowMore] = useState(false) + const [advancedBreakdown, setAdvancedBreakdown] = useState(false) + const pieRef = useRef() + const totalScore = useMemo(() => explanation.value, [explanation.value]) + const scoreData = useMemo(() => (parseScoreDetail(explanation) + .filter((detail) => detail !== null) + // Reduce duplicate details into single details. + .reduce((acc, cur) => { + const existingDetail = acc.find((detail) => detail.source === cur.source) + if (!existingDetail) acc.push(cur) + else { + // If the exact detail already exists, add the scores. + existingDetail.value += cur.value + } + return acc + }, []) + // Reduce details down further into single field matches, if advanced breakdown is disabled. + // E.g. `name:heart` and `name:heart disease` would get merged into the same detail at this step. + .reduce((acc, cur) => { + if (advancedBreakdown) { + acc.push(cur) + return acc + } + const existingDetailWithField = acc.find((detail) => detail.fieldMatch === cur.fieldMatch) + if (!existingDetailWithField) acc.push(cur) + else { + // If a detail exists with the current field match, and not in advanced breakdown, add the scores. + if (Array.isArray(existingDetailWithField.termMatch)) existingDetailWithField.termMatch.push(cur.termMatch) + else existingDetailWithField.termMatch = [existingDetailWithField.termMatch, cur.termMatch] + existingDetailWithField.value += cur.value + } + return acc + }, []) + // Reduce details into chart data + .reduce((acc, cur) => { + const { fieldMatch, termMatch, source, value } = cur + const [fieldMatchName, fieldMatchDescription] = ( + fieldMatch === "name" ? ["Name", "The name of this concept"] + : fieldMatch === "description" ? ["Description", "The description of this concept"] + : fieldMatch === "search_terms" ? ["Search terms", "Synonymous names for this concept"] + : fieldMatch === "optional_terms" ? ["Related terms", "Search terms for concepts related to this concept"] + : ["", ""] + ) + const advancedBreakdownString = ` ${ fieldMatchName.endsWith("s") ? "contain" : "contains"} the term "${ termMatch }"` + if (fieldMatch && termMatch) acc.push({ + name: `${ fieldMatchName }`, + description: `${ fieldMatchDescription }${ advancedBreakdown ? advancedBreakdownString : ""}`, + key: source, + matchedField: fieldMatch, + matchedTerms: termMatch, + failedParse: false, + value + }) + else acc.push({ + name: "Unknown", + description: "Could not parse explanation for this score component.", + key: source, + matchedField: null, + matchedTerms: null, + failedParse: true, + value + }) + return acc + }, []) + .sort((a, b) => b.value - a.value) + ), [explanation, advancedBreakdown]) + const colorMap = useMemo(() => palette.slice(0, scoreData.length), [scoreData]) + const pieConfig = useMemo(() => ({ + data: scoreData, + // appendPadding: 10, + autoFit: false, + height: 310, + width: 400, + angleField: "value", + colorField: "key", + radius: 1, + innerRadius: 0.70, + renderer: "svg", + legend: false, + statistic: { + title: { + style: { + fontSize: "1em" + } + }, + content: { + offsetY: 4, + content: explanation.value.toFixed(1), + style: { + fontSize: "1em" + } + } + }, + label: { + type: "inner", + offset: "-50%", + style: { + textAlign: "center" + }, + autoRotate: false, + formatter: (v) => v.value.toFixed(1) + }, + tooltip: { + formatter: (datum) => { + const { name, value } = scoreData.find((d) => d.key === datum.key) + return { name, value } + } + }, + color: colorMap + }), [scoreData, colorMap, explanation]) + return ( + +
+
+ + Score breakdown + +
+ Why am I seeing this result? +
+
+
+ setAdvancedBreakdown(!advancedBreakdown) } /> + Advanced +
+
+
+ + + { + (showMore ? scoreData : scoreData.slice(0, SHOW_MORE_CUTOFF - 1)).map((detail, i) => ( +
+
+ + { detail.name } + + +
+
+ { detail.description } + {/* { !detail.failedParse && ( + + { Array.isArray(detail.matchedTerms) ? detail.matchedTerms.join("/") : detail.matchedTerms } +  matched with  + { result[detail.matchedField] } +
+ } + placement="bottom" + trigger="hover" + iconProps={{ style: { marginLeft: 6, fontSize: 14, color: "rgba(0, 0, 0, 0.45)" } }} + /> + ) } */} +
+
+ )) + } + { scoreData.length > SHOW_MORE_CUTOFF && ( + + ) } +
+ + + ) +} \ No newline at end of file diff --git a/src/components/search/concept-modal/tabs/index.js b/src/components/search/concept-modal/tabs/index.js index 574c5984..bbceb139 100644 --- a/src/components/search/concept-modal/tabs/index.js +++ b/src/components/search/concept-modal/tabs/index.js @@ -2,5 +2,6 @@ export * from './cdes' export * from './knowledge-graphs' export * from './overview' export * from './studies' +export * from './explanation' export * from './tranql' export * from './robokop' \ No newline at end of file diff --git a/src/components/search/concept-modal/tabs/studies/study-variable.js b/src/components/search/concept-modal/tabs/studies/study-variable.js index dd092abf..c9ddeee3 100644 --- a/src/components/search/concept-modal/tabs/studies/study-variable.js +++ b/src/components/search/concept-modal/tabs/studies/study-variable.js @@ -6,11 +6,11 @@ const { Text } = Typography export const StudyVariable = ({ variable, highlight, ...props }) => (
-   +   ({ variable.e_link ? { variable.id } : variable.id })
- +
) \ No newline at end of file diff --git a/src/components/search/concept-modal/tabs/studies/study.js b/src/components/search/concept-modal/tabs/studies/study.js index a58d5181..c3fb3d53 100644 --- a/src/components/search/concept-modal/tabs/studies/study.js +++ b/src/components/search/concept-modal/tabs/studies/study.js @@ -20,7 +20,7 @@ export const Study = ({ study, highlight, collapsed, ...panelProps }) => { - { ` ` } + { ` ` } ({ study.c_id }) } diff --git a/src/components/search/context.js b/src/components/search/context.js index 4c97a8d3..8d6fe84a 100644 --- a/src/components/search/context.js +++ b/src/components/search/context.js @@ -29,6 +29,7 @@ export const HelxSearch = ({ children }) => { const [isLoadingConcepts, setIsLoadingConcepts] = useState(false); const [error, setError] = useState({}) const [conceptPages, setConceptPages] = useState({}) + const [conceptTypes, setConceptTypes] = useState({}) // const [concepts, setConcepts] = useState([]) const [totalConcepts, setTotalConcepts] = useState(0) const [currentPage, setCurrentPage] = useState(1) @@ -52,6 +53,7 @@ export const HelxSearch = ({ children }) => { const [searchHistory, setSearchHistory] = useLocalStorage('search_history', []) /** Abort controllers */ + const fetchConceptsController = useRef() const searchSelectedResultController = useRef() // const selectedResultLoading = useMemo(() => selectedResult && selectedResult.loading === true, [selectedResult]) @@ -90,10 +92,11 @@ export const HelxSearch = ({ children }) => { } } - const executeConceptSearch = async ({ query, offset, size }, axiosOptions) => { + const executeConceptSearch = async ({ query, types, offset, size }, axiosOptions) => { const params = { index: 'concepts_index', query, + types, offset, size } @@ -148,30 +151,10 @@ export const HelxSearch = ({ children }) => { } }, [executeConceptSearch, validationReducer]) - const filteredConceptPages = useMemo(() => { - if (typeFilter === null) return conceptPages - return Object.fromEntries(Object.entries(conceptPages).map(([page, concepts]) => { - return [ - page, - concepts.filter((concept) => concept.type === typeFilter) - ] - })) - }, [conceptPages, typeFilter]) - - const conceptTypes = useMemo(() => Object.values(conceptPages).flat().reduce((acc, cur) => { - if (!acc.includes(cur.type)) acc.push(cur.type) - return acc - }, []), [conceptPages]) - const conceptTypeCounts = useMemo(() => Object.values(conceptPages).flat().reduce((acc, cur) => { - if (!acc.hasOwnProperty(cur.type)) acc[cur.type] = 0 - acc[cur.type] += 1 - return acc - }, {}), [conceptPages]) - const concepts = useMemo(() => { - if (!filteredConceptPages[currentPage]) return [] - else return filteredConceptPages[currentPage] - }, [filteredConceptPages, currentPage]) + if (!conceptPages[currentPage]) return [] + else return conceptPages[currentPage] + }, [conceptPages, currentPage]) const setLayout = (newLayout) => { // Only track when layout changes @@ -228,6 +211,7 @@ export const HelxSearch = ({ children }) => { useEffect(() => { setConceptPages({}) + setConceptTypes({}) setError({}) setTypeFilter(null) setSelectedResult(null) @@ -235,23 +219,36 @@ export const HelxSearch = ({ children }) => { setVariableResults([]) }, [query]) + useEffect(() => { + setConceptPages({}) + setCurrentPage(1) + setError({}) + }, [typeFilter]) useEffect(() => { const fetchConcepts = async () => { if (conceptPages[currentPage]) { return } - console.log("Load page", query, currentPage) + + fetchConceptsController.current?.abort() + fetchConceptsController.current = new AbortController() + setIsLoadingConcepts(true) - // await new Promise((resolve) => setTimeout(resolve, 2500)) const startTime = Date.now() try { const result = await executeConceptSearch({ query: query, + types: typeFilter ? [typeFilter] : undefined, offset: (currentPage - 1) * PER_PAGE, size: PER_PAGE + }, { + signal: fetchConceptsController.current.signal }) if (result && result.hits) { - const unsortedHits = result.hits.hits.map(r => r._source) + const unsortedHits = result.hits.hits.map(r => ({ + ...r._source, + explanation: r._explanation + })) // gather invalid concepts: remove from rendered concepts and dump to console. let hits = unsortedHits.reduce(validationReducer, { valid: [], invalid: [] }) if (hits.invalid.length) { @@ -263,6 +260,7 @@ export const HelxSearch = ({ children }) => { newConceptPages[currentPage] = hits.valid // setSelectedResult(null) setConceptPages(newConceptPages) + setConceptTypes(result.concept_types) setTotalConcepts(result.total_items) // setConcepts(hits.valid) setIsLoadingConcepts(false) @@ -270,25 +268,26 @@ export const HelxSearch = ({ children }) => { } else { const newConceptPages = { ...conceptPages } newConceptPages[currentPage] = [] - // setSelectedResult(null) setConceptPages(newConceptPages) - // setConcepts([]) + setConceptTypes({}) setTotalConcepts(0) setIsLoadingConcepts(false) analyticsEvents.searchExecuted(query, Date.now() - startTime, 0) } } catch (error) { - console.log(error) - setError({ message: 'An error occurred!' }) - setTotalConcepts(0) - setIsLoadingConcepts(false) - analyticsEvents.searchExecuted(query, Date.now() - startTime, 0, error) + if (error.name !== "CanceledError") { + console.log(error) + setError({ message: 'An error occurred!' }) + setTotalConcepts(0) + setIsLoadingConcepts(false) + analyticsEvents.searchExecuted(query, Date.now() - startTime, 0, error) + } } } if (query) { fetchConcepts() } - }, [query, currentPage, conceptPages, helxSearchUrl, analyticsEvents]) + }, [query, currentPage, conceptPages, typeFilter, helxSearchUrl, analyticsEvents]) useEffect(() => { setPageCount(Math.ceil(totalConcepts / PER_PAGE)) @@ -486,13 +485,13 @@ export const HelxSearch = ({ children }) => { fetchKnowledgeGraphs, fetchCDEs, fetchVariablesForConceptId, inputRef, error, isLoadingConcepts, - concepts, totalConcepts, conceptPages: filteredConceptPages, + concepts, totalConcepts, conceptPages, currentPage, setCurrentPage, perPage: PER_PAGE, pageCount, selectedResult, setSelectedResult, searchSelectedResult, layout, setLayout, setFullscreenResult, typeFilter, setTypeFilter, searchHistory, setSearchHistory, - conceptTypes, conceptTypeCounts, + conceptTypes, variableStudyResults, variableStudyResultCount, variableError, variableResults, isLoadingVariableResults, totalVariableResults diff --git a/src/components/search/form/form.js b/src/components/search/form/form.js index 6381034f..154091b6 100644 --- a/src/components/search/form/form.js +++ b/src/components/search/form/form.js @@ -175,7 +175,7 @@ export const SearchForm = ({ type=undefined, ...props }) => { return ( - + ) @@ -225,7 +225,7 @@ export const SearchForm = ({ type=undefined, ...props }) => { suffix={ type === MINIMAL ? (
- +
) : undefined diff --git a/src/components/search/knowledge-graphs/knowledge-graphs.js b/src/components/search/knowledge-graphs/knowledge-graphs.js index f63f635c..cf465b55 100644 --- a/src/components/search/knowledge-graphs/knowledge-graphs.js +++ b/src/components/search/knowledge-graphs/knowledge-graphs.js @@ -21,7 +21,7 @@ const KnowledgeGraph = ({ graph, highlight }) => { }, [graph]) const Highlighter = useCallback(({ ...props }) => ( - <_Highlighter searchWords={highlight} {...props}/> + <_Highlighter autoEscape={ true } searchWords={highlight} {...props}/> ), [highlight]) return interactions.map((interaction, i) => ( diff --git a/src/components/search/results/concepts-grid-layout/concept-search-results.js b/src/components/search/results/concepts-grid-layout/concept-search-results.js index 2c5aa48b..0f53cf3d 100644 --- a/src/components/search/results/concepts-grid-layout/concept-search-results.js +++ b/src/components/search/results/concepts-grid-layout/concept-search-results.js @@ -9,13 +9,13 @@ const { Text } = Typography const { useBreakpoint } = AntGrid export const ConceptSearchResults = () => { - const { query, conceptPages, perPage, currentPage, pageCount, typeFilter, + const { query, conceptPages, perPage, currentPage, pageCount, isLoadingConcepts, error, setCurrentPage, setSelectedResult } = useHelxSearch() const { md } = useBreakpoint(); const concepts = useMemo(() => Object.values(conceptPages).flat(), [conceptPages]) const hasMore = useMemo(() => ( - !typeFilter && !isLoadingConcepts && (currentPage < pageCount || pageCount === 0) - ), [typeFilter, isLoadingConcepts, currentPage, pageCount]) + !isLoadingConcepts && (currentPage < pageCount || pageCount === 0) + ), [isLoadingConcepts, currentPage, pageCount]) const getNextPage = useCallback(() => { setCurrentPage(currentPage + 1) }, [currentPage]) @@ -62,12 +62,8 @@ export const ConceptSearchResults = () => { currentPage === pageCount ? ( null ) : ( - typeFilter ? ( - Disable filters to load more results. - ) : ( - (currentPage === 0 || currentPage < pageCount || isLoadingConcepts) && ( - - ) + (currentPage === 0 || currentPage < pageCount || isLoadingConcepts) && ( + ) ) } diff --git a/src/components/search/results/expanded-results-layout/expanded-results-sidebar.js b/src/components/search/results/expanded-results-layout/expanded-results-sidebar.js index 5be0de16..e1ff4b4f 100644 --- a/src/components/search/results/expanded-results-layout/expanded-results-sidebar.js +++ b/src/components/search/results/expanded-results-layout/expanded-results-sidebar.js @@ -21,7 +21,7 @@ export const ExpandedResultsSidebar = ({ expanded, setExpanded }) => { const { conceptPages, selectedResult, setSelectedResult, pageCount, isLoadingConcepts, setLayout, - currentPage, setCurrentPage, typeFilter + currentPage, setCurrentPage } = useHelxSearch() const { md } = useBreakpoint() const cardRefs = useRef({}) @@ -29,8 +29,8 @@ export const ExpandedResultsSidebar = ({ expanded, setExpanded }) => { const concepts = useMemo(() => Object.values(conceptPages).flat(), [conceptPages]) const hasMore = useMemo(() => ( - !typeFilter && !isLoadingConcepts && (currentPage < pageCount || pageCount === 0) - ), [typeFilter, isLoadingConcepts, currentPage, pageCount]) + !isLoadingConcepts && (currentPage < pageCount || pageCount === 0) + ), [isLoadingConcepts, currentPage, pageCount]) const getNextPage = useCallback(() => { setCurrentPage(currentPage + 1) @@ -101,16 +101,11 @@ export const ExpandedResultsSidebar = ({ expanded, setExpanded }) => { { currentPage === pageCount ? ( null - ) : ( - typeFilter ? ( - Disable filters to load more results. - ) : ( - (currentPage === 0 || currentPage < pageCount || isLoadingConcepts) && ( - - ) + ) : ((currentPage === 0 || currentPage < pageCount || isLoadingConcepts) && ( + ) ) } diff --git a/src/components/search/results/results-header/index.js b/src/components/search/results/results-header/index.js index 9754ed49..a1e41d40 100644 --- a/src/components/search/results/results-header/index.js +++ b/src/components/search/results/results-header/index.js @@ -23,7 +23,7 @@ export const ResultsHeader = ({ variables=false, type=FULL, ...props }) => { currentPage, pageCount, layout, setLayout, typeFilter, setTypeFilter, - conceptTypeCounts, conceptPages + conceptTypes, conceptPages } = useHelxSearch() const { analyticsEvents } = useAnalytics() const { md } = useBreakpoint() @@ -50,7 +50,7 @@ export const ResultsHeader = ({ variables=false, type=FULL, ...props }) => { { variables ? ( `${ variableStudyResultCount } studies and ${ totalVariableResults } variables` ) : ( - `${ totalConcepts } concepts (${ Object.keys(conceptPages).length } of ${ pageCount } pages)` + `${ totalConcepts } concepts` ) } { !variables && ( @@ -84,7 +84,7 @@ export const ResultsHeader = ({ variables=false, type=FULL, ...props }) => { > { - Object.entries(conceptTypeCounts).sort((a, b) => b[1] - a[1]).map(([conceptType, count]) => ( + Object.entries(conceptTypes).sort((a, b) => b[1] - a[1]).map(([conceptType, count]) => ( )) } diff --git a/src/index.css b/src/index.css index 5d845d72..39dd4549 100644 --- a/src/index.css +++ b/src/index.css @@ -7,6 +7,7 @@ body { margin: 0; font-family: 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + /* font-family: -apple-system,BlinkMacSystemFont,segoe ui,Roboto,helvetica neue,Arial,noto sans,sans-serif,apple color emoji,segoe ui emoji,segoe ui symbol,noto color emoji; */ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }