Skip to content

Commit 6369655

Browse files
Refactor Observable plot: Add useSqlPlot hook and make file per plot
1 parent e222758 commit 6369655

File tree

5 files changed

+159
-96
lines changed

5 files changed

+159
-96
lines changed
Lines changed: 3 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
11
import style from "./Charts.module.css";
2-
import { useEffect, useRef } from "react";
32

43
import type { ImportedRepository } from "../../types";
5-
import { SqlProvider, makeSeafowlHTTPContext, useSql } from "@madatdata/react";
4+
import { SqlProvider, makeSeafowlHTTPContext } from "@madatdata/react";
65

7-
import * as Plot from "@observablehq/plot";
86
import { useMemo } from "react";
97

10-
import {
11-
stargazersLineChartQuery,
12-
type StargazersLineChartRow,
13-
} from "./sql-queries";
8+
import { StargazersChart } from "./charts/StargazersChart";
149

1510
export interface ChartsProps {
1611
importedRepository: ImportedRepository;
@@ -32,71 +27,9 @@ export const Charts = ({ importedRepository }: ChartsProps) => {
3227
return (
3328
<div className={style.charts}>
3429
<SqlProvider dataContext={seafowlDataContext}>
30+
<h3>Stargazers</h3>
3531
<StargazersChart {...importedRepository} />
3632
</SqlProvider>
3733
</div>
3834
);
3935
};
40-
41-
const StargazersChart = ({
42-
splitgraphNamespace,
43-
splitgraphRepository,
44-
}: ImportedRepository) => {
45-
const containerRef = useRef<HTMLDivElement>();
46-
47-
const { response, error } = useSql<StargazersLineChartRow>(
48-
stargazersLineChartQuery({ splitgraphNamespace, splitgraphRepository })
49-
);
50-
51-
const stargazers = useMemo(() => {
52-
return !response || error
53-
? []
54-
: (response.rows ?? []).map((r) => ({
55-
...r,
56-
starred_at: new Date(r.starred_at),
57-
}));
58-
}, [response, error]);
59-
60-
useEffect(() => {
61-
if (stargazers === undefined) {
62-
return;
63-
}
64-
65-
const plot = Plot.plot({
66-
y: { grid: true },
67-
color: { scheme: "burd" },
68-
marks: [
69-
Plot.lineY(stargazers, {
70-
x: "starred_at",
71-
y: "cumulative_stars",
72-
}),
73-
// NOTE: We don't have username when querying Seafowl because it's within a JSON object,
74-
// and seafowl doesn't support querying inside JSON objects
75-
// Plot.tip(
76-
// stargazers,
77-
// Plot.pointer({
78-
// x: "starred_at",
79-
// y: "cumulative_stars",
80-
// title: (d) => `${d.username} was stargazer #${d.cumulative_stars}`,
81-
// })
82-
// ),
83-
],
84-
});
85-
86-
// There is a bug(?) in useSql where, since we can't give it dependencies, it
87-
// will re-run even with splitgraphNamespace and splitgraphRepository are undefined,
88-
// which results in an error querying Seafowl. So just don't render the chart in that case.
89-
if (splitgraphNamespace && splitgraphRepository) {
90-
containerRef.current.append(plot);
91-
}
92-
93-
return () => plot.remove();
94-
}, [stargazers]);
95-
96-
return (
97-
<>
98-
<h3>Stargazers</h3>
99-
<div ref={containerRef} />
100-
</>
101-
);
102-
};
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import * as Plot from "@observablehq/plot";
2+
import { useSqlPlot } from "../useSqlPlot";
3+
import type { ImportedRepository, TargetSplitgraphRepo } from "../../../types";
4+
5+
// Assume meta namespace contains both the meta tables, and all imported repositories and tables
6+
const META_NAMESPACE =
7+
process.env.NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_NAMESPACE;
8+
9+
/**
10+
* A simple line graph showing the number of stargazers over time
11+
*/
12+
export const StargazersChart = ({
13+
splitgraphNamespace,
14+
splitgraphRepository,
15+
}: ImportedRepository) => {
16+
const renderPlot = useSqlPlot({
17+
sqlParams: { splitgraphNamespace, splitgraphRepository },
18+
buildQuery: stargazersLineChartQuery,
19+
mapRows: (r: StargazersLineChartRow) => ({
20+
...r,
21+
starred_at: new Date(r.starred_at),
22+
}),
23+
isRenderable: (p) => !!p.splitgraphRepository,
24+
makePlotOptions: (stargazers) => ({
25+
y: { grid: true },
26+
color: { scheme: "burd" },
27+
marks: [
28+
Plot.lineY(stargazers, {
29+
x: "starred_at",
30+
y: "cumulative_stars",
31+
}),
32+
],
33+
}),
34+
});
35+
36+
return renderPlot();
37+
};
38+
39+
/** Shape of row returned by {@link stargazersLineChartQuery} */
40+
export type StargazersLineChartRow = {
41+
username: string;
42+
cumulative_stars: number;
43+
starred_at: string;
44+
};
45+
46+
/** Time series of GitHub stargazers for the given repository */
47+
export const stargazersLineChartQuery = ({
48+
splitgraphNamespace = META_NAMESPACE,
49+
splitgraphRepository,
50+
}: TargetSplitgraphRepo) => {
51+
return `SELECT
52+
COUNT(*) OVER (ORDER BY starred_at) AS cumulative_stars,
53+
starred_at
54+
FROM
55+
"${splitgraphNamespace}/${splitgraphRepository}"."stargazers"
56+
ORDER BY starred_at;`;
57+
};
Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
1-
import type { ImportedRepository } from "../../types";
1+
import type { ImportedRepository, TargetSplitgraphRepo } from "../../types";
22

33
// Assume meta namespace contains both the meta tables, and all imported repositories and tables
44
const META_NAMESPACE =
55
process.env.NEXT_PUBLIC_SPLITGRAPH_GITHUB_ANALYTICS_META_NAMESPACE;
66

7-
type TargetSplitgraphRepo = {
8-
splitgraphNamespace?: string;
9-
splitgraphRepository: string;
10-
};
11-
127
/**
138
* Raw query to select all columns in the stargazers table, which can be
149
* run on both Splitgraph and Seafowl.
@@ -32,23 +27,3 @@ FROM
3227
"${splitgraphNamespace}/${splitgraphRepository}"."stargazers"
3328
LIMIT 100;`;
3429
};
35-
36-
/** Shape of row returned by {@link stargazersLineChartQuery} */
37-
export type StargazersLineChartRow = {
38-
username: string;
39-
cumulative_stars: number;
40-
starred_at: string;
41-
};
42-
43-
/** Time series of GitHub stargazers for the given repository */
44-
export const stargazersLineChartQuery = ({
45-
splitgraphNamespace = META_NAMESPACE,
46-
splitgraphRepository,
47-
}: TargetSplitgraphRepo) => {
48-
return `SELECT
49-
COUNT(*) OVER (ORDER BY starred_at) AS cumulative_stars,
50-
starred_at
51-
FROM
52-
"${splitgraphNamespace}/${splitgraphRepository}"."stargazers"
53-
ORDER BY starred_at;`;
54-
};
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { useCallback, useEffect, useRef } from "react";
2+
3+
import { UnknownObjectShape, useSql } from "@madatdata/react";
4+
5+
import * as Plot from "@observablehq/plot";
6+
import { useMemo } from "react";
7+
8+
/**
9+
* A hook that returns a render function for a Plot chart built from the
10+
* results of a SQL query. All of the generic parameters should be inferrable
11+
* based on the parameters passed to the `sqlParams` parameter.
12+
*
13+
* @returns A render function which returns a value that can be returned from a Component
14+
*/
15+
export const useSqlPlot = <
16+
RowShape extends UnknownObjectShape,
17+
SqlParams extends object,
18+
MappedRow extends UnknownObjectShape
19+
>({
20+
sqlParams,
21+
mapRows,
22+
buildQuery,
23+
makePlotOptions,
24+
isRenderable,
25+
}: {
26+
/**
27+
* The input parameters, an object that should match the first and only parameter
28+
* of the `buildQuery` callback
29+
* */
30+
sqlParams: SqlParams;
31+
/**
32+
* An optional function to map the rows returned by the SQL query to a different
33+
* row shape, which is most often useful for things like converting a string column
34+
* to a `Date` object.
35+
*/
36+
mapRows?: (row: RowShape) => MappedRow;
37+
/**
38+
* A builder function that returns a SQL query given a set of parameters, which
39+
* will be the parameters passed as the `sqlParams` parameter.
40+
*/
41+
buildQuery: (sqlParams: SqlParams) => string;
42+
/**
43+
* A function to call after receiving the result of the SQL query (and mapping
44+
* its rows if applicable), to create the options given to Observable {@link Plot.plot}
45+
*/
46+
makePlotOptions: (rows: MappedRow[]) => Plot.PlotOptions;
47+
/**
48+
* A function to call to determine if the chart is renderable. This is helpful
49+
* during server side rendering, when Observable Plot doesn't typically work well,
50+
* and also when the response from the query is empty, for example because the `useSql`
51+
* hook executed before its parameters were set (this works around an inconvenience in
52+
* `useSql` where it does not take any parameters and so always executes on first render)
53+
*/
54+
isRenderable?: (sqlParams: SqlParams) => boolean;
55+
}) => {
56+
const containerRef = useRef<HTMLDivElement>();
57+
58+
const { response, error } = useSql<RowShape>(buildQuery(sqlParams));
59+
60+
const mappedRows = useMemo(() => {
61+
return !response || error
62+
? []
63+
: (response.rows ?? []).map(
64+
mapRows ?? ((r) => r as unknown as MappedRow)
65+
);
66+
}, [response, error]);
67+
68+
const plotOptions = useMemo(() => makePlotOptions(mappedRows), [mappedRows]);
69+
70+
useEffect(() => {
71+
if (mappedRows === undefined) {
72+
return;
73+
}
74+
75+
const plot = Plot.plot(plotOptions);
76+
77+
// There is a bug(?) in useSql where, since we can't give it dependencies, it
78+
// will re-run even with splitgraphNamespace and splitgraphRepository are undefined,
79+
// which results in an error querying Seafowl. So just don't render the chart in that case.
80+
if (!isRenderable || isRenderable(sqlParams)) {
81+
containerRef.current.append(plot);
82+
}
83+
84+
return () => plot.remove();
85+
}, [mappedRows]);
86+
87+
const renderPlot = useCallback(
88+
() => <div ref={containerRef} />,
89+
[containerRef]
90+
);
91+
92+
return renderPlot;
93+
};

examples/nextjs-import-airbyte-github-export-seafowl/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,8 @@ export interface ImportedRepository {
44
splitgraphNamespace: string;
55
splitgraphRepository: string;
66
}
7+
8+
export interface TargetSplitgraphRepo {
9+
splitgraphNamespace?: string;
10+
splitgraphRepository: string;
11+
}

0 commit comments

Comments
 (0)