Skip to content

Commit

Permalink
Merge pull request #2925 from owid/collections
Browse files Browse the repository at this point in the history
🎉 Collections
  • Loading branch information
ikesau authored Dec 4, 2023
2 parents cd72dd7 + f5ba7fa commit e65dcc5
Show file tree
Hide file tree
Showing 15 changed files with 403 additions and 6 deletions.
10 changes: 10 additions & 0 deletions adminSiteServer/mockSiteRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
countryProfileCountryPage,
renderExplorerPage,
makeAtomFeedNoTopicPages,
renderDynamicCollectionPage,
renderTopChartsCollectionPage,
} from "../baker/siteRenderers.js"
import {
BAKED_BASE_URL,
Expand Down Expand Up @@ -134,6 +136,14 @@ mockSiteRouter.get("/*", async (req, res, next) => {
)
})

mockSiteRouter.get("/collection/top-charts", async (_, res) => {
return res.send(await renderTopChartsCollectionPage())
})

mockSiteRouter.get("/collection/custom", async (_, res) => {
return res.send(await renderDynamicCollectionPage())
})

mockSiteRouter.get("/grapher/:slug", async (req, res) => {
const entity = await Chart.getBySlug(req.params.slug)
if (!entity) throw new JsonError("No such chart", 404)
Expand Down
10 changes: 10 additions & 0 deletions baker/SiteBaker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
renderPost,
renderGdoc,
makeAtomFeedNoTopicPages,
renderDynamicCollectionPage,
renderTopChartsCollectionPage,
} from "../baker/siteRenderers.js"
import {
bakeGrapherUrls,
Expand Down Expand Up @@ -424,6 +426,14 @@ export class SiteBaker {
`${this.bakedSiteDir}/search.html`,
await renderSearchPage()
)
await this.stageWrite(
`${this.bakedSiteDir}/collection/custom.html`,
await renderDynamicCollectionPage()
)
await this.stageWrite(
`${this.bakedSiteDir}/collection/top-charts.html`,
await renderTopChartsCollectionPage()
)
await this.stageWrite(
`${this.bakedSiteDir}/404.html`,
await renderNotFoundPage()
Expand Down
27 changes: 27 additions & 0 deletions baker/siteRenderers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
import { BlogIndexPage } from "../site/BlogIndexPage.js"
import { FrontPage } from "../site/FrontPage.js"
import { ChartsIndexPage, ChartIndexItem } from "../site/ChartsIndexPage.js"
import { DynamicCollectionPage } from "../site/collections/DynamicCollectionPage.js"
import { StaticCollectionPage } from "../site/collections/StaticCollectionPage.js"
import { SearchPage } from "../site/search/SearchPage.js"
import { NotFoundPage } from "../site/NotFoundPage.js"
import { DonatePage } from "../site/DonatePage.js"
Expand Down Expand Up @@ -139,6 +141,31 @@ export const renderChartsPage = async (
)
}

export async function renderTopChartsCollectionPage() {
const charts: string[] = await queryMysql(
`
SELECT SUBSTRING_INDEX(url, '/', -1) AS slug
FROM analytics_pageviews
WHERE url LIKE "%https://ourworldindata.org/grapher/%"
ORDER BY views_14d DESC
LIMIT 50
`
).then((rows) => rows.map((row: { slug: string }) => row.slug))

const props = {
baseUrl: BAKED_BASE_URL,
title: "Top Charts",
introduction:
"The 50 most viewed charts from the last 14 days on Our World in Data.",
charts,
}
return renderToHtmlPage(<StaticCollectionPage {...props} />)
}

export function renderDynamicCollectionPage() {
return renderToHtmlPage(<DynamicCollectionPage baseUrl={BAKED_BASE_URL} />)
}

export const renderGdocsPageBySlug = async (
slug: string
): Promise<string | undefined> => {
Expand Down
3 changes: 2 additions & 1 deletion db/refreshPageviewsFromDatasette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import Papa from "papaparse"
import * as db from "./db.js"

async function downloadAndInsertCSV(): Promise<void> {
const csvUrl = "http://datasette-private/owid/pageviews.csv?_size=max"
const csvUrl =
"http://datasette-private/owid/analytics_pageviews.csv?_size=max"
const response = await fetch(csvUrl)

if (!response.ok) {
Expand Down
5 changes: 3 additions & 2 deletions packages/@ourworldindata/grapher/src/core/Grapher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ export interface GrapherProgrammaticInterface extends GrapherInterface {
shouldOptimizeForHorizontalSpace?: boolean

manager?: GrapherManager
instanceRef?: React.RefObject<Grapher>
}

export interface GrapherManager {
Expand Down Expand Up @@ -1780,7 +1781,7 @@ export class Grapher
static renderGrapherIntoContainer(
config: GrapherProgrammaticInterface,
containerNode: Element
): Grapher | null {
): React.RefObject<Grapher> {
const grapherInstanceRef = React.createRef<Grapher>()

let ErrorBoundary = React.Fragment as React.ComponentType // use React.Fragment as a sort of default error boundary if Bugsnag is not available
Expand Down Expand Up @@ -1835,7 +1836,7 @@ export class Grapher
Bugsnag?.notify("ResizeObserver not available")
}

return grapherInstanceRef.current
return grapherInstanceRef
}

static renderSingleGrapherOnGrapherPage(
Expand Down
1 change: 1 addition & 0 deletions packages/@ourworldindata/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ export {
type RefDictionary,
ScaleType,
type SerializedGridProgram,
DYNAMIC_COLLECTION_PAGE_CONTAINER_ID,
SiteFooterContext,
SortBy,
type SortConfig,
Expand Down
3 changes: 3 additions & 0 deletions packages/@ourworldindata/utils/src/owidTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1481,10 +1481,13 @@ export enum GdocsContentSource {
Gdocs = "gdocs",
}

export const DYNAMIC_COLLECTION_PAGE_CONTAINER_ID = "dynamic-collection-page"

export enum SiteFooterContext {
gdocsDocument = "gdocsDocument", // the rendered version (on the site)
grapherPage = "grapherPage",
dataPageV2 = "dataPageV2",
dynamicCollectionPage = "dynamicCollectionPage",
explorerPage = "explorerPage",
default = "default",
}
Expand Down
30 changes: 30 additions & 0 deletions site/collections/CollectionsPage.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
.collections-page {
background-color: $gray-10;

.collections-page__header {
background-color: #fff;
margin-bottom: 24px;
}
.collection-title {
margin-bottom: 0;
}
.collection-explanation {
color: $blue-60;
margin-bottom: 32px;

&:not(:last-of-type) {
margin-bottom: 0;
}
}

figure {
width: 100%;
height: $grapher-height;
margin: 0;
margin-bottom: 48px;

@include sm-only {
height: 95vh;
}
}
}
169 changes: 169 additions & 0 deletions site/collections/DynamicCollection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import React from "react"
import ReactDOM from "react-dom"
import { BAKED_BASE_URL } from "../../settings/clientSettings.js"
import { DYNAMIC_COLLECTION_PAGE_CONTAINER_ID } from "@ourworldindata/utils"
import {
IReactionDisposer,
ObservableMap,
computed,
observable,
reaction,
} from "mobx"
import { observer } from "mobx-react"
import { WindowGraphers } from "./DynamicCollectionPage.js"
import { Grapher } from "@ourworldindata/grapher"

interface DynamicCollectionProps {
baseUrl: string
initialDynamicCollection?: string
}

/**
* After the MultiEmbedder has mounted a Grapher, we poll grapherRef until grapherRef.current is defined,
* and then update the window.graphers Map with it.
*
* This is what allows us to use a reaction in the DynamicCollection component to update the URL whenever a Grapher is updated.
*/
export function embedDynamicCollectionGrapher(
grapherRef: React.RefObject<Grapher>,
figure: Element
) {
const interval = setInterval(() => {
if (grapherRef.current) {
const originalSlug =
grapherRef.current.slug + grapherRef.current.queryStr

const index = figure.getAttribute("data-grapher-index")

const windowGrapher = window.graphers.get(
`${originalSlug}-${index}`
)

if (windowGrapher) {
windowGrapher.grapher = grapherRef.current
}
clearInterval(interval)
}
}, 1000)
}

@observer
export class DynamicCollection extends React.Component<DynamicCollectionProps> {
@observable initialDynamicCollection? = this.props.initialDynamicCollection
@observable graphers: undefined | WindowGraphers = undefined
pollInterval: null | ReturnType<typeof setInterval> = null
disposers: IReactionDisposer[] = []

@computed get allGrapherSlugsAndQueryStrings() {
if (!this.graphers) return []

// If the grapher hasn't mounted yet, we use the original slugAndQueryString
// This allows us to update the URL if users interact with graphers that have mounted
// while still keeping the unmounted graphers in the URL in the right place
const slugsAndQueryStrings = new Array(this.graphers.size)

for (const [originalSlugAndUrl, { index, grapher }] of this.graphers) {
if (!grapher) {
// Strip index suffix from originalSlugAndUrl
const withoutIndex = originalSlugAndUrl.replace(/-\d+$/, "")
slugsAndQueryStrings[index] = encodeURIComponent(withoutIndex)
} else {
slugsAndQueryStrings[index] = encodeURIComponent(
`${grapher.slug}${grapher.queryStr}`
)
}
}

return slugsAndQueryStrings
}

componentDidMount() {
this.pollInterval = setInterval(this.pollForGraphers, 1000)
}

pollForGraphers = () => {
if (typeof window !== "undefined" && window.graphers) {
this.graphers = window.graphers
clearInterval(this.pollInterval!)
this.setupReaction()
}
}

setupReaction = () => {
this.disposers.push(
reaction(
() => this.allGrapherSlugsAndQueryStrings,
(allGrapherSlugsAndQueryStrings: string[]) => {
const newUrl = `${
this.props.baseUrl
}/collection/custom?charts=${allGrapherSlugsAndQueryStrings.join(
"+"
)}`
history.replaceState({}, "", newUrl)
}
)
)
}

renderInterior = () => {
if (!this.initialDynamicCollection)
return (
<p className="span-cols-12">
No charts were added to this collection.
{/* TODO: Algolia search? */}
</p>
)
return (
<div className="grid span-cols-12">
{this.initialDynamicCollection
.split(" ")
.map((chartSlug, index) => (
<figure
key={index}
data-grapher-src={`${this.props.baseUrl}/grapher/${chartSlug}`}
data-grapher-index={index}
className="span-cols-6 span-md-cols-12"
/>
))}
</div>
)
}

render() {
return (
<>
{/* TODO: Add Algolia search to add new charts? */}
{this.renderInterior()}
</>
)
}
}

export function hydrateDynamicCollectionPage() {
const container = document.querySelector(
`#${DYNAMIC_COLLECTION_PAGE_CONTAINER_ID}`
)
const urlParams = new URLSearchParams(window.location.search)
const initialDynamicCollection = urlParams.get("charts") || ""
window.graphers = new ObservableMap()
const entries = initialDynamicCollection.split(" ").entries()
for (const [index, chartSlug] of entries) {
window.graphers.set(
// Include index in the key so that we can have multiple of the same chart
// This gets tracked in the DOM via data-grapher-index, so that the MultiEmbedder can update the correct object
// when the grapher mounts
`${chartSlug}-${index}`,
observable({
index,
grapher: undefined,
})
)
}
ReactDOM.hydrate(
<DynamicCollection
baseUrl={BAKED_BASE_URL}
initialDynamicCollection={initialDynamicCollection}
/>,
container
)
}
Loading

0 comments on commit e65dcc5

Please sign in to comment.