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;
+}