diff --git a/.eslintignore b/.eslintignore index 9e84750c1e7..4667d8bf136 100644 --- a/.eslintignore +++ b/.eslintignore @@ -15,3 +15,4 @@ wordpress/web/wp/wp-content/** wordpress/vendor/** packages/@ourworldindata/*/dist/ dist/ +.vscode/ diff --git a/.vscode/launch.json b/.vscode/launch.json index 4ef08867736..25ccd948758 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,6 +13,9 @@ "skipFiles": [ "/**" ], + "skipFiles": [ + "/**" + ], "type": "node" }, { @@ -25,6 +28,10 @@ "${fileBasenameNoExtension}.js", "--watch" ], + "args": [ + "${fileBasenameNoExtension}.js", + "--watch" + ], "console": "integratedTerminal" // "internalConsoleOptions": "neverOpen" }, @@ -70,7 +77,7 @@ "skipFiles": [ "/**" ], - "type": "node" + "type": "node", }, { "name": "Run SVGTester", @@ -79,17 +86,24 @@ "skipFiles": [ "/**" ], + "skipFiles": [ + "/**" + ], "type": "node", "args": [ "-g", "367" ] + "args": [ + "-g", + "367" + ] }, { "name": "Launch admin server", "program": "${workspaceFolder}/itsJustJavascript/adminSiteServer/app.js", "request": "launch", - "type": "node" + "type": "node", }, { "name": "Attach to node", @@ -115,4 +129,4 @@ "port": 9000 } ] -} +} \ No newline at end of file diff --git a/Makefile b/Makefile index 315e584f7d9..badc7844aa5 100644 --- a/Makefile +++ b/Makefile @@ -24,23 +24,24 @@ help: @echo 'Available commands:' @echo @echo ' GRAPHER ONLY' - @echo ' make up start dev environment via docker-compose and tmux' - @echo ' make down stop any services still running' - @echo ' make refresh (while up) download a new grapher snapshot and update MySQL' - @echo ' make migrate (while up) run any outstanding db migrations' - @echo ' make test run full suite (except db tests) of CI checks including unit tests' - @echo ' make dbtest run db test suite that needs a running mysql db' - @echo ' make svgtest compare current rendering against reference SVGs' + @echo ' make up start dev environment via docker-compose and tmux' + @echo ' make down stop any services still running' + @echo ' make refresh (while up) download a new grapher snapshot and update MySQL' + @echo ' make refresh.pageviews (while up) download and load pageviews from the private datasette instance' + @echo ' make migrate (while up) run any outstanding db migrations' + @echo ' make test run full suite (except db tests) of CI checks including unit tests' + @echo ' make dbtest run db test suite that needs a running mysql db' + @echo ' make svgtest compare current rendering against reference SVGs' @echo @echo ' GRAPHER + WORDPRESS (staff-only)' - @echo ' make up.full start dev environment via docker-compose and tmux' - @echo ' make down.full stop any services still running' - @echo ' make refresh.wp download a new wordpress snapshot and update MySQL' - @echo ' make refresh.full do a full MySQL update of both wordpress and grapher' + @echo ' make up.full start dev environment via docker-compose and tmux' + @echo ' make down.full stop any services still running' + @echo ' make refresh.wp download a new wordpress snapshot and update MySQL' + @echo ' make refresh.full do a full MySQL update of both wordpress and grapher' @echo @echo ' OPS (staff-only)' - @echo ' make deploy Deploy your local site to production' - @echo ' make stage Deploy your local site to staging' + @echo ' make deploy Deploy your local site to production' + @echo ' make stage Deploy your local site to staging' @echo up: export DEBUG = 'knex:query' @@ -136,6 +137,10 @@ refresh: @echo '==> Updating grapher database' @. ./.env && DATA_FOLDER=tmp-downloads ./devTools/docker/refresh-grapher-data.sh +refresh.pageviews: + @echo '==> Refreshing pageviews' + yarn && yarn buildTsc && yarn refreshPageviews + refresh.wp: @echo '==> Downloading wordpress data' ./devTools/docker/download-wordpress-mysql.sh diff --git a/baker/GrapherBaker.tsx b/baker/GrapherBaker.tsx index 21d154755f7..10d9c6c6411 100644 --- a/baker/GrapherBaker.tsx +++ b/baker/GrapherBaker.tsx @@ -28,6 +28,7 @@ import { getRelatedArticles, getRelatedCharts, getRelatedChartsForVariable, + getRelatedResearchAndWritingForVariable, isWordpressAPIEnabled, isWordpressDBEnabled, } from "../db/wpdb.js" @@ -227,7 +228,7 @@ export async function renderDataPageV2({ } const datapageData = await getDatapageDataV2( variableMetadata, - grapherConfigForVariable ?? {} + grapher ?? {} ) const firstTopicTag = datapageData.topicTagsLinks?.[0] @@ -272,6 +273,10 @@ export async function renderDataPageV2({ variableId, grapher && "id" in grapher ? [grapher.id as number] : [] ) + + datapageData.relatedResearch = + await getRelatedResearchAndWritingForVariable(variableId) + return renderToHtmlPage( x.id) }) + .delete() + .execute() + } + } return newPost ? newPost.slug : undefined } diff --git a/db/migration/1692042923850-AddPostsLinks.ts b/db/migration/1692042923850-AddPostsLinks.ts new file mode 100644 index 00000000000..ffccf1b01ea --- /dev/null +++ b/db/migration/1692042923850-AddPostsLinks.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class AddPostsLinks1692042923850 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + queryRunner.query(`-- sql + CREATE TABLE posts_links ( + id int NOT NULL AUTO_INCREMENT, + sourceId int NOT NULL, + target varchar(2047) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs NOT NULL, + linkType enum('url','grapher','explorer', 'gdoc') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs DEFAULT NULL, + componentType varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs NOT NULL, + text varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs NOT NULL, + queryString varchar(2047) COLLATE utf8mb4_0900_as_cs NOT NULL, + hash varchar(2047) COLLATE utf8mb4_0900_as_cs NOT NULL, + PRIMARY KEY (id), + KEY sourceId (sourceId), + CONSTRAINT posts_links_ibfk_1 FOREIGN KEY (sourceId) REFERENCES posts (id) + ) ENGINE=InnoDB;`) + } + + public async down(queryRunner: QueryRunner): Promise { + queryRunner.query(`-- sql + DROP TABLE IF EXISTS posts_links; + `) + } +} diff --git a/db/model/PostLink.ts b/db/model/PostLink.ts new file mode 100644 index 00000000000..35db9cb5d3c --- /dev/null +++ b/db/model/PostLink.ts @@ -0,0 +1,47 @@ +import { Entity, PrimaryGeneratedColumn, Column, BaseEntity } from "typeorm" +import { formatUrls } from "../../site/formatting.js" +import { Url } from "@ourworldindata/utils" +import { getLinkType, getUrlTarget } from "@ourworldindata/components" + +@Entity("posts_links") +export class PostLink extends BaseEntity { + @PrimaryGeneratedColumn() id!: number + // TODO: posts is not a TypeORM but a Knex class so we can't use a TypeORM relationship here yet + + @Column({ type: "int", nullable: false }) sourceId!: number + + @Column() linkType!: "gdoc" | "url" | "grapher" | "explorer" + @Column() target!: string + @Column() queryString!: string + @Column() hash!: string + @Column() componentType!: string + @Column() text!: string + + static createFromUrl({ + url, + sourceId, + text = "", + componentType = "", + }: { + url: string + sourceId: number + text?: string + componentType?: string + }): PostLink { + const formattedUrl = formatUrls(url) + const urlObject = Url.fromURL(formattedUrl) + const linkType = getLinkType(formattedUrl) + const target = getUrlTarget(formattedUrl) + const queryString = urlObject.queryStr + const hash = urlObject.hash + return PostLink.create({ + target, + linkType, + queryString, + hash, + sourceId, + text, + componentType, + }) + } +} diff --git a/db/refreshPageviewsFromDatasette.ts b/db/refreshPageviewsFromDatasette.ts new file mode 100644 index 00000000000..e46145ac650 --- /dev/null +++ b/db/refreshPageviewsFromDatasette.ts @@ -0,0 +1,49 @@ +// index.ts +import fetch from "node-fetch" +import Papa from "papaparse" +import * as db from "./db.js" + +async function downloadAndInsertCSV(): Promise { + const csvUrl = "http://datasette-private/owid/pageviews.csv?_size=max" + const response = await fetch(csvUrl) + + if (!response.ok) { + throw new Error( + `Failed to fetch CSV: ${response.statusText} from ${csvUrl}` + ) + } + + const csvText = await response.text() + const parsedData = Papa.parse(csvText, { + header: true, + }) + + if (parsedData.errors.length > 1) { + console.error("Errors while parsing CSV:", parsedData.errors) + return + } + + const onlyValidRows = [...parsedData.data].filter( + (row) => Object.keys(row as any).length === 5 + ) as any[] + + console.log("Parsed CSV data:", onlyValidRows.length, "rows") + console.log("Columns:", parsedData.meta.fields) + + await db.knexRaw("TRUNCATE TABLE pageviews") + + await db.knexInstance().batchInsert("pageviews", onlyValidRows) + console.log("CSV data inserted successfully!") +} + +const main = async (): Promise => { + try { + await downloadAndInsertCSV() + } catch (e) { + console.error(e) + } finally { + await db.closeTypeOrmAndKnexConnections() + } +} + +main() diff --git a/db/syncPostsToGrapher.ts b/db/syncPostsToGrapher.ts index a8d6be458bb..7c8ad2c4b7c 100644 --- a/db/syncPostsToGrapher.ts +++ b/db/syncPostsToGrapher.ts @@ -3,12 +3,16 @@ import * as wpdb from "./wpdb.js" import * as db from "./db.js" -import { keyBy, PostRow } from "@ourworldindata/utils" +import { excludeNullish, groupBy, keyBy, PostRow } from "@ourworldindata/utils" import { postsTable, select } from "./model/Post.js" +import { PostLink } from "./model/PostLink.js" const zeroDateString = "0000-00-00 00:00:00" const blockRefRegex = //g +const prominentLinkRegex = /"linkUrl":"(?[^"]+)"/g +const anyHrefRegex = /href="(?[^"]+)"/g +const anySrcRegex = /src="(?[^"]+)"/g interface ReusableBlock { ID: number @@ -99,6 +103,78 @@ export async function buildReusableBlocksResolver(): Promise + `${item.linkType} - ${item.target} - ${item.hash} - ${item.queryString}` + +export function getLinksToAddAndRemoveForPost( + post: PostRow, + existingLinksForPost: PostLink[], + content: string, + postId: number +): { linksToAdd: PostLink[]; linksToDelete: PostLink[] } { + const linksInDb = groupBy( + existingLinksForPost, + postLinkCompareStringGenerator + ) + + const allHrefs = excludeNullish( + [...content.matchAll(anyHrefRegex)].map((x) => + x.groups?.["url"] + ? { + url: x.groups?.["url"].substring(0, 2046), + sourceId: postId, + componentType: "href", + } + : undefined + ) + ) + const allSrcs = excludeNullish( + [...content.matchAll(anySrcRegex)].map((x) => + x.groups?.["url"] + ? { + url: x.groups?.["url"].substring(0, 2046), + sourceId: postId, + componentType: "src", + } + : undefined + ) + ) + const allProminentLinks = excludeNullish( + [...content.matchAll(prominentLinkRegex)].map((x) => + x.groups?.["url"] + ? { + url: x.groups?.["url"].substring(0, 2046), + sourceId: postId, + componentType: "prominent-link", + } + : undefined + ) + ) + const linksInDocument = keyBy( + [ + ...allHrefs.map((link) => PostLink.createFromUrl(link)), + ...allSrcs.map((link) => PostLink.createFromUrl(link)), + ...allProminentLinks.map((link) => PostLink.createFromUrl(link)), + ], + postLinkCompareStringGenerator + ) + + const linksToAdd: PostLink[] = [] + const linksToDelete: PostLink[] = [] + + // This is doing a set difference, but we want to do the set operation on a subset + // of fields (the ones we stringify into the compare key) while retaining the full + // object so that we can e.g. delete efficiently by id later on. + for (const [linkInDocCompareKey, linkInDoc] of Object.entries( + linksInDocument + )) + if (!(linkInDocCompareKey in linksInDb)) linksToAdd.push(linkInDoc) + for (const [linkInDbCompareKey, linkInDb] of Object.entries(linksInDb)) + if (!(linkInDbCompareKey in linksInDocument)) + linksToDelete.push(...linkInDb) + return { linksToAdd, linksToDelete } +} + const syncPostsToGrapher = async (): Promise => { const dereferenceReusableBlocksFn = await buildReusableBlocksResolver() @@ -222,6 +298,24 @@ const syncPostsToGrapher = async (): Promise => { featured_image: post.featured_image || "", } }) as PostRow[] + const postLinks = await PostLink.find() + const postLinksById = groupBy(postLinks, (link) => link.sourceId) + + const linksToAdd: PostLink[] = [] + const linksToDelete: PostLink[] = [] + + for (const post of rows) { + const existingLinksForPost = postLinksById[post.ID] + const content = post.post_content as string + const linksToModify = getLinksToAddAndRemoveForPost( + post, + existingLinksForPost, + content, + post.ID + ) + linksToAdd.push(...linksToModify.linksToAdd) + linksToDelete.push(...linksToModify.linksToDelete) + } await db.knexInstance().transaction(async (t) => { if (toDelete.length) @@ -233,10 +327,29 @@ const syncPostsToGrapher = async (): Promise => { else await t.insert(row).into(postsTable) } }) + + // TODO: unify our DB access and then do everything in one transaction + if (linksToAdd.length) { + console.log("linksToAdd", linksToAdd.length) + await PostLink.createQueryBuilder() + .insert() + .into(PostLink) + .values(linksToAdd) + .execute() + } + + if (linksToDelete.length) { + console.log("linksToDelete", linksToDelete.length) + await PostLink.createQueryBuilder() + .where("id in (:ids)", { ids: linksToDelete.map((x) => x.id) }) + .delete() + .execute() + } } const main = async (): Promise => { try { + await db.getConnection() await syncPostsToGrapher() } finally { await wpdb.singleton.end() diff --git a/db/wpdb.ts b/db/wpdb.ts index fe8cc90f812..d57b0a7171e 100644 --- a/db/wpdb.ts +++ b/db/wpdb.ts @@ -36,6 +36,9 @@ import { orderBy, IMAGES_DIRECTORY, uniqBy, + sortBy, + DataPageRelatedResearch, + isString, OwidGdocType, Tag, } from "@ourworldindata/utils" @@ -660,6 +663,164 @@ export const getRelatedChartsForVariable = async ( `) } +interface RelatedResearchQueryResult { + linkTargetSlug: string + componentType: string + chartSlug: string + title: string + postSlug: string + chartId: number + authors: string + thumbnail: string + pageviews: number + post_source: string + tags: string +} +export const getRelatedResearchAndWritingForVariable = async ( + variableId: number +): Promise => { + const wp_posts: RelatedResearchQueryResult[] = await db.queryMysql( + `-- sql + -- What we want here is to get from the variable to the charts + -- to the posts and collect different pieces of information along the way + -- One important complication is that the slugs that are used in posts to + -- embed charts can either be the current slugs or old slugs that are redirected + -- now. + select + distinct + pl.target as linkTargetSlug, + pl.componentType as componentType, + coalesce(charts_via_redirects.slug, c.slug) as chartSlug, + p.title as title, + p.slug as postSlug, + coalesce(csr.chart_id, c.id) as chartId, + p.authors as authors, + p.featured_image as thumbnail, + coalesce(pv.views_365d, 0) as pageviews, + 'wordpress' as post_source, + (select coalesce(JSON_ARRAYAGG(t.name), JSON_ARRAY()) + from post_tags pt + join tags t on pt.tag_id = t.id + where pt.post_id = p.id + ) as tags + from + posts_links pl + join posts p on + pl.sourceId = p.id + left join charts c on + pl.target = c.slug + left join chart_slug_redirects csr on + pl.target = csr.slug + left join charts charts_via_redirects on + charts_via_redirects.id = csr.chart_id + left join chart_dimensions cd on + cd.chartId = coalesce(csr.chart_id, c.id) + left join pageviews pv on + pv.url = concat('https://ourworldindata.org/', p.slug ) + left join posts_gdocs pg on + pg.id = p.gdocSuccessorId + left join posts_gdocs pgs on + pgs.slug = p.slug + left join post_tags pt on + pt.post_id = p.id + where + -- we want only urls that point to grapher charts + pl.linkType = 'grapher' + -- componentType src is for those links that matched the anySrcregex (not anyHrefRegex or prominentLinkRegex) + -- this means that only the links that are of the iframe kind will be kept - normal a href style links will + -- be disregarded + and componentType = 'src' + and cd.variableId = ? + and cd.property in ('x', 'y') -- ignore cases where the indicator is size, color etc + and p.status = 'publish' -- only use published wp posts + and coalesce(pg.published, 0) = 0 -- ignore posts if the wp post has a published gdoc successor. The + -- coalesce makes sure that if there is no gdoc successor then + -- the filter keeps the post + and coalesce(pgs.published, 0) = 0 -- ignore posts if there is a gdoc post with the same slug that is published + -- this case happens for example for topic pages that are newly created (successorId is null) + -- but that replace an old wordpress page + + `, + [variableId] + ) + + const gdocs_posts: RelatedResearchQueryResult[] = await db.queryMysql( + `-- sql + select + distinct + pl.target as linkTargetSlug, + pl.componentType as componentType, + c.slug as chartSlug, + p.content ->> '$.title' as title, + p.slug as postSlug, + coalesce(csr.chart_id, c.id) as chartId, + p.content ->> '$.authors' as authors, + p.content ->> '$."featured-image"' as thumbnail, + coalesce(pv.views_365d, 0) as pageviews, + 'gdocs' as post_source, + (select coalesce(JSON_ARRAYAGG(t.name), JSON_ARRAY()) + from posts_gdocs_x_tags pt + join tags t on pt.tagId = t.id + where pt.gdocId = p.id + ) as tags + from + posts_gdocs_links pl + join posts_gdocs p on + pl.sourceId = p.id + left join charts c on + pl.target = c.slug + left join chart_slug_redirects csr on + pl.target = csr.slug + join chart_dimensions cd on + cd.chartId = c.id + left join pageviews pv on + pv.url = concat('https://ourworldindata.org/', p.slug ) + left join posts_gdocs_x_tags pt on + pt.gdocId = p.id + where + pl.linkType = 'grapher' + and componentType = 'chart' -- this filters out links in tags and keeps only embedded charts + and cd.variableId = ? + and cd.property in ('x', 'y') -- ignore cases where the indicator is size, color etc + and p.published = 1 + and p.content ->> '$.type' != 'fragment'`, + [variableId] + ) + + const combined = [...wp_posts, ...gdocs_posts] + + // we could do the sorting in the SQL query if we'd union the two queries + // but it seemed easier to understand if we do the sort here + const sorted = sortBy(combined, (post) => -post.pageviews) + + const allSortedRelatedResearch = sorted.map((post) => { + const parsedAuthors = JSON.parse(post.authors) + // The authors in the gdocs table are just a list of strings, but in the wp_posts table + // they are a list of objects with an "author" key and an "order" key. We want to normalize this so that + // we can just use the same code to display the authors in both cases. + let authors + if (parsedAuthors.length > 0 && !isString(parsedAuthors[0])) { + authors = sortBy(parsedAuthors, (author) => author.order).map( + (author: any) => author.author + ) + } else authors = parsedAuthors + const parsedTags = post.tags !== "" ? JSON.parse(post.tags) : [] + + return { + title: post.title, + url: `/${post.postSlug}`, + variantName: "", + authors, + imageUrl: post.thumbnail, + tags: parsedTags, + } + }) + // the queries above use distinct but because of the information we pull in if the same piece of research + // uses different charts that all use a single indicator we would get duplicates for the post to link to so + // here we deduplicate by url. The first item is retained by uniqBy, latter ones are discarded. + return uniqBy(allSortedRelatedResearch, "url") +} + export const getRelatedArticles = async ( chartId: number ): Promise => { diff --git a/package.json b/package.json index 2c1bdf9e2b8..07e49461fdf 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "fixPrettierAll": "yarn prettier --write \"**/*.{tsx,ts,jsx,js,json,md,html,css,scss,yml}\"", "runRegionsUpdater": "node --enable-source-maps ./itsJustJavascript/devTools/regionsUpdater/update.js", "runDbMigrations": "yarn typeorm migration:run -d itsJustJavascript/db/dataSource.js", + "refreshPageviews": "node --enable-source-maps ./itsJustJavascript/db/refreshPageviewsFromDatasette.js", "revertLastDbMigration": "yarn typeorm migration:revert -d itsJustJavascript/db/dataSource.js", "runPostUpdateHook": "node --enable-source-maps ./itsJustJavascript/baker/postUpdatedHook.js", "startAdminServer": "node --enable-source-maps ./itsJustJavascript/adminSiteServer/app.js", diff --git a/packages/@ourworldindata/utils/src/owidTypes.ts b/packages/@ourworldindata/utils/src/owidTypes.ts index f0c9488dc49..99a6205a4c4 100644 --- a/packages/@ourworldindata/utils/src/owidTypes.ts +++ b/packages/@ourworldindata/utils/src/owidTypes.ts @@ -1606,6 +1606,7 @@ export interface DataPageRelatedResearch { url: string authors: string[] imageUrl: string + tags: string[] } export interface DataPageRelatedData { diff --git a/site/DataPageV2.tsx b/site/DataPageV2.tsx index 2c2d3a16afa..3cb35a6db4a 100644 --- a/site/DataPageV2.tsx +++ b/site/DataPageV2.tsx @@ -135,6 +135,7 @@ export const DataPageV2 = (props: { { datapageData, faqEntries, + canonicalUrl, } )}`, }} diff --git a/site/DataPageV2Content.tsx b/site/DataPageV2Content.tsx index fcf53211b0f..82bd3971649 100644 --- a/site/DataPageV2Content.tsx +++ b/site/DataPageV2Content.tsx @@ -19,12 +19,14 @@ import { uniq, pick, formatAuthors, + intersection, } from "@ourworldindata/utils" import { AttachmentsContext, DocumentContext } from "./gdocs/OwidGdoc.js" import StickyNav from "./blocks/StickyNav.js" import cx from "classnames" import { DebugProvider } from "./gdocs/DebugContext.js" import dayjs from "dayjs" +import { IMAGE_HOSTING_CDN_URL } from "../settings/clientSettings.js" declare global { interface Window { _OWID_DATAPAGEV2_PROPS: DataPageV2ContentFields @@ -190,6 +192,31 @@ export const DataPageV2Content = ({ ? `“Data Page: ${datapageData.title}”, part of the following publication: ${datapageData.primaryTopic.citation}. Data adapted from ${producers}. Retrieved from ${canonicalUrl} [online resource]` : `“Data Page: ${datapageData.title}”. Our World in Data (${currentYear}). Data adapted from ${producers}. Retrieved from ${canonicalUrl} [online resource]` + const relatedResearchCandidates = datapageData.relatedResearch + const relatedResearch = + relatedResearchCandidates.length > 3 && + datapageData.topicTagsLinks?.length + ? relatedResearchCandidates.filter((research) => { + const shared = intersection( + research.tags, + datapageData.topicTagsLinks ?? [] + ) + return shared.length > 0 + }) + : relatedResearchCandidates + for (const item of relatedResearch) { + // TODO: these are workarounds to not link to the (not really existing) template pages for energy or co2 + // country profiles but instead to the topic page at the country selector. + if (item.url === "/co2-country-profile") + item.url = + "/co2-and-greenhouse-gas-emissions#co2-and-greenhouse-gas-emissions-country-profiles" + else if (item.url === "/energy-country-profile") + item.url = "/energy#country-profiles" + else if (item.url === "/coronavirus-country-profile") + item.url = "/coronavirus#coronavirus-country-profiles" + } + // TODO: mark topic pages + return (
- {datapageData.relatedResearch && - datapageData.relatedResearch.length > 0 && ( -
-

- Related research and writing -

-
- {datapageData.relatedResearch.map( - (research: any) => ( - - -
-

- {research.title} -

-
- {formatAuthors({ - authors: - research.authors, - })} -
-
-
- ) - )} -
+ {relatedResearch && relatedResearch.length > 0 && ( + + )} {!!datapageData.relatedData?.length && (

+
+
+
+
+
+

+ ) + + const multipleChartsView = (
@@ -91,6 +108,8 @@ export const RelatedCharts = ({
) + + return charts.length === 1 ? singleChartView : multipleChartsView } export const runRelatedCharts = (charts: RelatedChart[]) => {