From 636965551772e13d41f09886dd1b4d6b52adab47 Mon Sep 17 00:00:00 2001 From: Miles Richardson Date: Thu, 1 Jun 2023 16:08:16 -0400 Subject: [PATCH] Refactor Observable plot: Add `useSqlPlot` hook and make file per plot --- .../components/RepositoryAnalytics/Charts.tsx | 73 +-------------- .../charts/StargazersChart.tsx | 57 ++++++++++++ .../RepositoryAnalytics/sql-queries.ts | 27 +----- .../RepositoryAnalytics/useSqlPlot.tsx | 93 +++++++++++++++++++ .../types.ts | 5 + 5 files changed, 159 insertions(+), 96 deletions(-) create mode 100644 examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/charts/StargazersChart.tsx create mode 100644 examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/useSqlPlot.tsx diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/Charts.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/Charts.tsx index 0e0f2f3..6eea242 100644 --- a/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/Charts.tsx +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/Charts.tsx @@ -1,16 +1,11 @@ import style from "./Charts.module.css"; -import { useEffect, useRef } from "react"; import type { ImportedRepository } from "../../types"; -import { SqlProvider, makeSeafowlHTTPContext, useSql } from "@madatdata/react"; +import { SqlProvider, makeSeafowlHTTPContext } from "@madatdata/react"; -import * as Plot from "@observablehq/plot"; import { useMemo } from "react"; -import { - stargazersLineChartQuery, - type StargazersLineChartRow, -} from "./sql-queries"; +import { StargazersChart } from "./charts/StargazersChart"; export interface ChartsProps { importedRepository: ImportedRepository; @@ -32,71 +27,9 @@ export const Charts = ({ importedRepository }: ChartsProps) => { return (
+

Stargazers

); }; - -const StargazersChart = ({ - splitgraphNamespace, - splitgraphRepository, -}: ImportedRepository) => { - const containerRef = useRef(); - - const { response, error } = useSql( - stargazersLineChartQuery({ splitgraphNamespace, splitgraphRepository }) - ); - - const stargazers = useMemo(() => { - return !response || error - ? [] - : (response.rows ?? []).map((r) => ({ - ...r, - starred_at: new Date(r.starred_at), - })); - }, [response, error]); - - useEffect(() => { - if (stargazers === undefined) { - return; - } - - const plot = Plot.plot({ - y: { grid: true }, - color: { scheme: "burd" }, - marks: [ - Plot.lineY(stargazers, { - x: "starred_at", - y: "cumulative_stars", - }), - // NOTE: We don't have username when querying Seafowl because it's within a JSON object, - // and seafowl doesn't support querying inside JSON objects - // Plot.tip( - // stargazers, - // Plot.pointer({ - // x: "starred_at", - // y: "cumulative_stars", - // title: (d) => `${d.username} was stargazer #${d.cumulative_stars}`, - // }) - // ), - ], - }); - - // There is a bug(?) in useSql where, since we can't give it dependencies, it - // will re-run even with splitgraphNamespace and splitgraphRepository are undefined, - // which results in an error querying Seafowl. So just don't render the chart in that case. - if (splitgraphNamespace && splitgraphRepository) { - containerRef.current.append(plot); - } - - return () => plot.remove(); - }, [stargazers]); - - return ( - <> -

Stargazers

-
- - ); -}; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/charts/StargazersChart.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/charts/StargazersChart.tsx new file mode 100644 index 0000000..72789c7 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/charts/StargazersChart.tsx @@ -0,0 +1,57 @@ +import * as Plot from "@observablehq/plot"; +import { useSqlPlot } from "../useSqlPlot"; +import type { ImportedRepository, TargetSplitgraphRepo } from "../../../types"; + +// Assume meta namespace contains both the meta tables, and all imported repositories and tables +const META_NAMESPACE = + process.env.NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_NAMESPACE; + +/** + * A simple line graph showing the number of stargazers over time + */ +export const StargazersChart = ({ + splitgraphNamespace, + splitgraphRepository, +}: ImportedRepository) => { + const renderPlot = useSqlPlot({ + sqlParams: { splitgraphNamespace, splitgraphRepository }, + buildQuery: stargazersLineChartQuery, + mapRows: (r: StargazersLineChartRow) => ({ + ...r, + starred_at: new Date(r.starred_at), + }), + isRenderable: (p) => !!p.splitgraphRepository, + makePlotOptions: (stargazers) => ({ + y: { grid: true }, + color: { scheme: "burd" }, + marks: [ + Plot.lineY(stargazers, { + x: "starred_at", + y: "cumulative_stars", + }), + ], + }), + }); + + return renderPlot(); +}; + +/** Shape of row returned by {@link stargazersLineChartQuery} */ +export type StargazersLineChartRow = { + username: string; + cumulative_stars: number; + starred_at: string; +}; + +/** Time series of GitHub stargazers for the given repository */ +export const stargazersLineChartQuery = ({ + splitgraphNamespace = META_NAMESPACE, + splitgraphRepository, +}: TargetSplitgraphRepo) => { + return `SELECT + COUNT(*) OVER (ORDER BY starred_at) AS cumulative_stars, + starred_at +FROM + "${splitgraphNamespace}/${splitgraphRepository}"."stargazers" +ORDER BY starred_at;`; +}; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/sql-queries.ts b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/sql-queries.ts index 47e1962..11acec8 100644 --- a/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/sql-queries.ts +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/sql-queries.ts @@ -1,14 +1,9 @@ -import type { ImportedRepository } from "../../types"; +import type { ImportedRepository, TargetSplitgraphRepo } from "../../types"; // Assume meta namespace contains both the meta tables, and all imported repositories and tables const META_NAMESPACE = process.env.NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_NAMESPACE; -type TargetSplitgraphRepo = { - splitgraphNamespace?: string; - splitgraphRepository: string; -}; - /** * Raw query to select all columns in the stargazers table, which can be * run on both Splitgraph and Seafowl. @@ -32,23 +27,3 @@ FROM "${splitgraphNamespace}/${splitgraphRepository}"."stargazers" LIMIT 100;`; }; - -/** Shape of row returned by {@link stargazersLineChartQuery} */ -export type StargazersLineChartRow = { - username: string; - cumulative_stars: number; - starred_at: string; -}; - -/** Time series of GitHub stargazers for the given repository */ -export const stargazersLineChartQuery = ({ - splitgraphNamespace = META_NAMESPACE, - splitgraphRepository, -}: TargetSplitgraphRepo) => { - return `SELECT - COUNT(*) OVER (ORDER BY starred_at) AS cumulative_stars, - starred_at -FROM - "${splitgraphNamespace}/${splitgraphRepository}"."stargazers" -ORDER BY starred_at;`; -}; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/useSqlPlot.tsx b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/useSqlPlot.tsx new file mode 100644 index 0000000..65dff18 --- /dev/null +++ b/examples/nextjs-import-airbyte-github-export-seafowl/components/RepositoryAnalytics/useSqlPlot.tsx @@ -0,0 +1,93 @@ +import { useCallback, useEffect, useRef } from "react"; + +import { UnknownObjectShape, useSql } from "@madatdata/react"; + +import * as Plot from "@observablehq/plot"; +import { useMemo } from "react"; + +/** + * A hook that returns a render function for a Plot chart built from the + * results of a SQL query. All of the generic parameters should be inferrable + * based on the parameters passed to the `sqlParams` parameter. + * + * @returns A render function which returns a value that can be returned from a Component + */ +export const useSqlPlot = < + RowShape extends UnknownObjectShape, + SqlParams extends object, + MappedRow extends UnknownObjectShape +>({ + sqlParams, + mapRows, + buildQuery, + makePlotOptions, + isRenderable, +}: { + /** + * The input parameters, an object that should match the first and only parameter + * of the `buildQuery` callback + * */ + sqlParams: SqlParams; + /** + * An optional function to map the rows returned by the SQL query to a different + * row shape, which is most often useful for things like converting a string column + * to a `Date` object. + */ + mapRows?: (row: RowShape) => MappedRow; + /** + * A builder function that returns a SQL query given a set of parameters, which + * will be the parameters passed as the `sqlParams` parameter. + */ + buildQuery: (sqlParams: SqlParams) => string; + /** + * A function to call after receiving the result of the SQL query (and mapping + * its rows if applicable), to create the options given to Observable {@link Plot.plot} + */ + makePlotOptions: (rows: MappedRow[]) => Plot.PlotOptions; + /** + * A function to call to determine if the chart is renderable. This is helpful + * during server side rendering, when Observable Plot doesn't typically work well, + * and also when the response from the query is empty, for example because the `useSql` + * hook executed before its parameters were set (this works around an inconvenience in + * `useSql` where it does not take any parameters and so always executes on first render) + */ + isRenderable?: (sqlParams: SqlParams) => boolean; +}) => { + const containerRef = useRef(); + + const { response, error } = useSql(buildQuery(sqlParams)); + + const mappedRows = useMemo(() => { + return !response || error + ? [] + : (response.rows ?? []).map( + mapRows ?? ((r) => r as unknown as MappedRow) + ); + }, [response, error]); + + const plotOptions = useMemo(() => makePlotOptions(mappedRows), [mappedRows]); + + useEffect(() => { + if (mappedRows === undefined) { + return; + } + + const plot = Plot.plot(plotOptions); + + // There is a bug(?) in useSql where, since we can't give it dependencies, it + // will re-run even with splitgraphNamespace and splitgraphRepository are undefined, + // which results in an error querying Seafowl. So just don't render the chart in that case. + if (!isRenderable || isRenderable(sqlParams)) { + containerRef.current.append(plot); + } + + return () => plot.remove(); + }, [mappedRows]); + + const renderPlot = useCallback( + () =>
, + [containerRef] + ); + + return renderPlot; +}; diff --git a/examples/nextjs-import-airbyte-github-export-seafowl/types.ts b/examples/nextjs-import-airbyte-github-export-seafowl/types.ts index 4c6cbc5..8183d8d 100644 --- a/examples/nextjs-import-airbyte-github-export-seafowl/types.ts +++ b/examples/nextjs-import-airbyte-github-export-seafowl/types.ts @@ -4,3 +4,8 @@ export interface ImportedRepository { splitgraphNamespace: string; splitgraphRepository: string; } + +export interface TargetSplitgraphRepo { + splitgraphNamespace?: string; + splitgraphRepository: string; +}