diff --git a/site/search/Search.scss b/site/search/Search.scss index 1c2f1454fb9..149d4760cb7 100644 --- a/site/search/Search.scss +++ b/site/search/Search.scss @@ -320,6 +320,25 @@ } } +.search-results__chart-hit-entities { + display: flex; + flex-wrap: wrap; + gap: 4px; + list-style: none; + font-size: 0.7em; + + li { + background-color: $blue-10; + padding: 4px 8px; + border-radius: 12px; + color: $blue-90; + + svg { + margin-right: 4px; + } + } +} + /* * Tabs / Filtering **/ diff --git a/site/search/SearchPanel.tsx b/site/search/SearchPanel.tsx index 1482fe17ec5..27e9088e518 100644 --- a/site/search/SearchPanel.tsx +++ b/site/search/SearchPanel.tsx @@ -1,11 +1,13 @@ import ReactDOM from "react-dom" -import React, { useCallback, useEffect, useState } from "react" +import React, { useCallback, useEffect, useMemo, useState } from "react" import cx from "classnames" import { keyBy, getWindowQueryParams, get, mapValues, + EntityName, + Url, } from "@ourworldindata/utils" import { InstantSearch, @@ -38,7 +40,11 @@ import { } from "./searchTypes.js" import { EXPLORERS_ROUTE_FOLDER } from "../../explorer/ExplorerConstants.js" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome/index.js" -import { faHeartBroken, faSearch } from "@fortawesome/free-solid-svg-icons" +import { + faHeartBroken, + faLocationDot, + faSearch, +} from "@fortawesome/free-solid-svg-icons" import { DEFAULT_SEARCH_PLACEHOLDER, getIndexName, @@ -51,7 +57,9 @@ import { import { DEFAULT_GRAPHER_HEIGHT, DEFAULT_GRAPHER_WIDTH, + setSelectedEntityNamesParam, } from "@ourworldindata/grapher" +import { pickEntitiesForChartHit } from "./SearchUtils.js" function PagesHit({ hit }: { hit: IPageHit }) { return ( @@ -80,13 +88,43 @@ function PagesHit({ hit }: { hit: IPageHit }) { ) } +const getChartQueryStr = (slug: string, entities: EntityName[]) => { + if (entities.length === 0) return "" + else { + return setSelectedEntityNamesParam( + Url.fromQueryParams({ + tab: "chart", + }), + entities + ).queryStr + } +} + function ChartHit({ hit }: { hit: IChartHit }) { const [imgLoaded, setImgLoaded] = useState(false) const [imgError, setImgError] = useState(false) + const entities = useMemo( + () => pickEntitiesForChartHit(hit), + // eslint-disable-next-line react-hooks/exhaustive-deps + [hit._highlightResult?.availableEntities] + ) + const queryStr = useMemo( + () => getChartQueryStr(hit.slug, entities), + [hit.slug, entities] + ) + const previewUrl = queryStr + ? `/grapher/thumbnail/${hit.slug}${queryStr}` + : `${BAKED_GRAPHER_URL}/exports/${hit.slug}.svg` + + useEffect(() => { + setImgLoaded(false) + setImgError(false) + }, [previewUrl]) + return ( )} setImgLoaded(true)} onError={() => setImgError(true)} /> @@ -117,6 +156,16 @@ function ChartHit({ hit }: { hit: IChartHit }) { {hit.variantName} + {entities.length > 0 && ( + + )} ) } diff --git a/site/search/SearchUtils.tsx b/site/search/SearchUtils.tsx new file mode 100644 index 00000000000..bab1a842d09 --- /dev/null +++ b/site/search/SearchUtils.tsx @@ -0,0 +1,38 @@ +import { HitAttributeHighlightResult } from "instantsearch.js/es/types/results.js" +import { IChartHit } from "./searchTypes.js" +import { EntityName } from "@ourworldindata/types" + +const removeHighlightTags = (text: string) => + text.replace(/<\/?(mark|strong)>/g, "") + +export function pickEntitiesForChartHit(hit: IChartHit): EntityName[] { + const availableEntitiesHighlighted = hit._highlightResult + ?.availableEntities as HitAttributeHighlightResult[] | undefined + + const pickedEntities = availableEntitiesHighlighted + ?.filter((highlightEntry) => { + // Keep the highlight if it is fully highlighted + if (highlightEntry.fullyHighlighted) return true + if (highlightEntry.matchLevel === "none") return false + + // Remove any trailing parentheses, e.g. "Africa (UN)" -> "Africa" + const withoutTrailingParens = removeHighlightTags( + highlightEntry.value + ).replace(/\s?\(.*\)$/, "") + + const matchedWordsLowerCase = highlightEntry.matchedWords.map( + (mw) => mw.toLowerCase() + ) + + // Keep the highlight if every word (except for trailing parens) is fully highlighted + // This will also highlight "Central African Republic" when searching for "african central republic", + // but that's probably okay + return withoutTrailingParens + .toLowerCase() + .split(" ") + .every((w) => matchedWordsLowerCase.includes(w)) + }) + .map((highlightEntry) => removeHighlightTags(highlightEntry.value)) + + return pickedEntities ?? [] +}