Skip to content

Commit

Permalink
🎉 add explorer thumbnail rendering
Browse files Browse the repository at this point in the history
  • Loading branch information
ikesau committed Nov 8, 2024
1 parent 4a15b85 commit ab0149f
Show file tree
Hide file tree
Showing 12 changed files with 309 additions and 87 deletions.
2 changes: 1 addition & 1 deletion _routes.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": 1,
"include": ["/grapher/*", "/deleted/*", "/donation/*"],
"include": ["/grapher/*", "/deleted/*", "/donation/*", "/explorers/*"],
"exclude": ["/grapher/_grapherRedirects.json"]
}
2 changes: 1 addition & 1 deletion functions/_common/grapherRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export const fetchAndRenderGrapher = async (

let initialized = false

async function renderSvgToPng(svg: string, options: ImageOptions) {
export async function renderSvgToPng(svg: string, options: ImageOptions) {
if (!initialized) {
await initializeSvg2Png(svg2png_wasm)
initialized = true
Expand Down
40 changes: 40 additions & 0 deletions functions/_common/grapherTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,43 @@ export async function initGrapher(

return grapher
}

/**
* Update og:url, og:image, and twitter:image meta tags to include the search parameters.
*/
export function rewriteMetaTags(
url: URL,
openGraphThumbnailUrl: string,
twitterThumbnailUrl: string,
page: Response
) {
// Take the origin (e.g. https://ourworldindata.org) from the canonical URL, which should appear before the image elements.
// If we fail to capture the origin, we end up with relative image URLs, which should also be okay.
let origin = ""

const rewriter = new HTMLRewriter()
.on('meta[property="og:url"]', {
// Replace canonical URL, otherwise the preview image will not include the search parameters.
element: (element) => {
const canonicalUrl = element.getAttribute("content")
element.setAttribute("content", canonicalUrl + url.search)
try {
origin = new URL(canonicalUrl).origin
} catch (e) {
console.error("Error parsing canonical URL", e)
}
},
})
.on('meta[property="og:image"]', {
element: (element) => {
element.setAttribute("content", origin + openGraphThumbnailUrl)
},
})
.on('meta[name="twitter:image"]', {
element: (element) => {
element.setAttribute("content", origin + twitterThumbnailUrl)
},
})

return rewriter.transform(page as unknown as Response)
}
150 changes: 150 additions & 0 deletions functions/explorers/[slug].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { Env, Etag, extensions } from "../_common/env.js"
import { extractOptions } from "../_common/imageOptions.js"
import { buildExplorerProps, Explorer } from "@ourworldindata/explorer"
import { handlePageNotFound } from "../_common/redirectTools.js"
import { renderSvgToPng } from "../_common/grapherRenderer.js"
import { IRequestStrict, Router, error, cors, png } from "itty-router"
import { Bounds, Url } from "@ourworldindata/utils"
import {
getSelectedEntityNamesParam,
migrateSelectedEntityNamesParam,
SelectionArray,
} from "@ourworldindata/grapher"
import { rewriteMetaTags } from "../_common/grapherTools.js"

const { preflight, corsify } = cors({
allowMethods: ["GET", "OPTIONS", "HEAD"],
})

const router = Router<
IRequestStrict,
[URL, Env, Etag, EventContext<unknown, any, Record<string, unknown>>]
>({
before: [preflight],
finally: [corsify],
})
router
.get(
`/explorers/:slug${extensions.svg}`,
async (_, { searchParams }, env) => {
console.log("Handling explorer SVG thumbnail request")
return handleThumbnailRequest(searchParams, env, "svg")
}
)
.get(
`/explorers/:slug${extensions.png}`,
async (_, { searchParams }, env) => {
console.log("Handling explorer PNG thumbnail request")
return handleThumbnailRequest(searchParams, env, "png")
}
)
.get(
"/explorers/:slug",
async ({ params: { slug } }, { searchParams }, env) => {
console.log("Handling explorer HTML page request")
return handleHtmlPageRequest(slug, searchParams, env)
}
)
.all("*", () => error(404, "Route not defined"))

async function handleThumbnailRequest(
searchParams: URLSearchParams,
env: Env,
extension: "png" | "svg"
) {
const options = extractOptions(searchParams)
const url = env.url
url.href = url.href.replace(`.${extension}`, "")
const explorerPage = await env.ASSETS.fetch(url, {
redirect: "manual",
})

try {
const html = await explorerPage.text()
const queryStr = url.searchParams.toString()
const urlObj = Url.fromURL(url.toString())
const [windowEntityNames] = [urlObj]
.map(migrateSelectedEntityNamesParam)
.map(getSelectedEntityNamesParam)

const selection = new SelectionArray(windowEntityNames)
const bounds = new Bounds(0, 0, options.svgWidth, options.svgHeight)
const explorerProps = await buildExplorerProps(
html,
queryStr,
selection,
bounds
)
const explorer = new Explorer(explorerProps)
explorer.updateGrapherFromExplorer()
while (!explorer.grapher.isReady) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
explorer.grapher.populateFromQueryParams(urlObj.queryParams)
const svg = explorer.grapher.generateStaticSvg()
if (extension === "svg") {
return new Response(svg, {
headers: {
"Content-Type": "image/svg+xml",
"Cache-Control": "public, max-age=600",
},
})
} else {
return png(await renderSvgToPng(svg, options))
}
} catch (e) {
console.error(e)
return error(500, e)
}
}

async function handleHtmlPageRequest(
slug: string,
_searchParams: URLSearchParams,
env: Env
) {
const url = env.url

const explorerPage = await env.ASSETS.fetch(url, {
redirect: "manual",
})

if (explorerPage.status === 404) {
return handlePageNotFound(env, explorerPage)
}

const openGraphThumbnailUrl = `/explorers/${slug}.png?imType=og${
url.search ? "&" + url.search.slice(1) : ""
}`
const twitterThumbnailUrl = `/explorers/${slug}.png?imType=twitter${
url.search ? "&" + url.search.slice(1) : ""
}`

const explorerPageWithUpdatedMetaTags = rewriteMetaTags(
url,
openGraphThumbnailUrl,
twitterThumbnailUrl,
explorerPage
)

return explorerPageWithUpdatedMetaTags
}

export const onRequest: PagesFunction<Env> = async (context) => {
context.passThroughOnException()
const { request, env } = context
const url = new URL(request.url)

return router
.fetch(
request,
url,
{ ...env, url },
request.headers.get("if-none-match"),
context
)
.catch(async (e) => {
console.log("Handling 404 for", url.pathname)
return error(500, e)
})
}
42 changes: 11 additions & 31 deletions functions/grapher/[slug].ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import {
handlePageNotFound,
getRedirectForUrl,
} from "../_common/redirectTools.js"
import { fetchUnparsedGrapherConfig } from "../_common/grapherTools.js"
import {
fetchUnparsedGrapherConfig,
rewriteMetaTags,
} from "../_common/grapherTools.js"
import { IRequestStrict, Router, StatusError, error, cors } from "itty-router"

const { preflight, corsify } = cors({
Expand Down Expand Up @@ -149,36 +152,13 @@ async function handleHtmlPageRequest(
url.search ? "&" + url.search.slice(1) : ""
}`

// Take the origin (e.g. https://ourworldindata.org) from the canonical URL, which should appear before the image elements.
// If we fail to capture the origin, we end up with relative image URLs, which should also be okay.
let origin = ""

// Rewrite the two meta tags that are used for a social media preview image.
const rewriter = new HTMLRewriter()
.on('meta[property="og:url"]', {
// Replace canonical URL, otherwise the preview image will not include the search parameters.
element: (element) => {
const canonicalUrl = element.getAttribute("content")
element.setAttribute("content", canonicalUrl + url.search)
try {
origin = new URL(canonicalUrl).origin
} catch (e) {
console.error("Error parsing canonical URL", e)
}
},
})
.on('meta[property="og:image"]', {
element: (element) => {
element.setAttribute("content", origin + openGraphThumbnailUrl)
},
})
.on('meta[name="twitter:image"]', {
element: (element) => {
element.setAttribute("content", origin + twitterThumbnailUrl)
},
})

return rewriter.transform(grapherPageResp as unknown as Response)
const grapherPageWithUpdatedMetaTags = rewriteMetaTags(
url,
openGraphThumbnailUrl,
twitterThumbnailUrl,
grapherPageResp
)
return grapherPageWithUpdatedMetaTags
}

async function handleConfigRequest(
Expand Down
25 changes: 20 additions & 5 deletions packages/@ourworldindata/explorer/src/Explorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ export interface ExplorerProps extends SerializedGridProgram {
bakedBaseUrl: string
bakedGrapherUrl: string
dataApiUrl: string
bounds?: Bounds
staticBounds?: Bounds
}

const LivePreviewComponent = (props: ExplorerProps) => {
Expand Down Expand Up @@ -178,7 +180,10 @@ const renderLivePreviewVersion = (props: ExplorerProps) => {
}

const isNarrow = () =>
window.screen.width < 450 || document.documentElement.clientWidth <= 800
typeof window === "undefined"
? false
: window.screen.width < 450 ||
document.documentElement.clientWidth <= 800

@observer
export class Explorer
Expand All @@ -190,6 +195,16 @@ export class Explorer
{
analytics = new GrapherAnalytics()

constructor(props: ExplorerProps) {
super(props)
this.explorerProgram = ExplorerProgram.fromJson(
props
).initDecisionMatrix(this.initialQueryParams)
this.grapher = new Grapher({
bounds: props.bounds,
staticBounds: props.staticBounds,
})
}
// caution: do a ctrl+f to find untyped usages
static renderSingleExplorerOnExplorerPage(
program: ExplorerProps,
Expand Down Expand Up @@ -465,7 +480,7 @@ export class Explorer
this.explorerProgram.constructTable(slug)
)

@action.bound private updateGrapherFromExplorer() {
@action.bound updateGrapherFromExplorer() {
switch (this.explorerProgram.chartCreationMode) {
case ExplorerChartCreationMode.FromGrapherId:
this.updateGrapherFromExplorerUsingGrapherId()
Expand Down Expand Up @@ -525,7 +540,7 @@ export class Explorer
)
}

@action.bound private updateGrapherFromExplorerUsingGrapherId() {
@action.bound updateGrapherFromExplorerUsingGrapherId() {
const grapher = this.grapher
if (!grapher) return

Expand Down Expand Up @@ -554,7 +569,7 @@ export class Explorer
grapher.downloadData()
}

@action.bound private async updateGrapherFromExplorerUsingVariableIds() {
@action.bound async updateGrapherFromExplorerUsingVariableIds() {
const grapher = this.grapher
if (!grapher) return
const {
Expand Down Expand Up @@ -725,7 +740,7 @@ export class Explorer
}
}

@action.bound private updateGrapherFromExplorerUsingColumnSlugs() {
@action.bound updateGrapherFromExplorerUsingColumnSlugs() {
const grapher = this.grapher
if (!grapher) return
const { tableSlug } = this.explorerProgram.explorerGrapherConfig
Expand Down
1 change: 1 addition & 0 deletions packages/@ourworldindata/explorer/src/ExplorerConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const UNSAVED_EXPLORER_PREVIEW_QUERYPARAMS =
"UNSAVED_EXPLORER_PREVIEW_QUERYPARAMS"

export const EMBEDDED_EXPLORER_DELIMITER = "\n//EMBEDDED_EXPLORER\n"
export const EXPLORER_CONSTANTS_DELIMITER = "\n//EXPLORER_CONSTANTS\n"
export const EMBEDDED_EXPLORER_GRAPHER_CONFIGS =
"\n//EMBEDDED_EXPLORER_GRAPHER_CONFIGS\n"
export const EMBEDDED_EXPLORER_PARTIAL_GRAPHER_CONFIGS =
Expand Down
Loading

0 comments on commit ab0149f

Please sign in to comment.