Skip to content

Commit

Permalink
Refactor Observable plot: Add useSqlPlot hook and make file per plot
Browse files Browse the repository at this point in the history
  • Loading branch information
milesrichardson committed Jun 1, 2023
1 parent e222758 commit 6369655
Show file tree
Hide file tree
Showing 5 changed files with 159 additions and 96 deletions.
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -32,71 +27,9 @@ export const Charts = ({ importedRepository }: ChartsProps) => {
return (
<div className={style.charts}>
<SqlProvider dataContext={seafowlDataContext}>
<h3>Stargazers</h3>
<StargazersChart {...importedRepository} />
</SqlProvider>
</div>
);
};

const StargazersChart = ({
splitgraphNamespace,
splitgraphRepository,
}: ImportedRepository) => {
const containerRef = useRef<HTMLDivElement>();

const { response, error } = useSql<StargazersLineChartRow>(
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 (
<>
<h3>Stargazers</h3>
<div ref={containerRef} />
</>
);
};
Original file line number Diff line number Diff line change
@@ -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;`;
};
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;`;
};
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>();

const { response, error } = useSql<RowShape>(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(
() => <div ref={containerRef} />,
[containerRef]
);

return renderPlot;
};
5 changes: 5 additions & 0 deletions examples/nextjs-import-airbyte-github-export-seafowl/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,8 @@ export interface ImportedRepository {
splitgraphNamespace: string;
splitgraphRepository: string;
}

export interface TargetSplitgraphRepo {
splitgraphNamespace?: string;
splitgraphRepository: string;
}

0 comments on commit 6369655

Please sign in to comment.