Skip to content

Commit 65f2059

Browse files
committed
feat: Add resolution/lookback support to charts
1 parent 4b49c17 commit 65f2059

File tree

7 files changed

+643
-175
lines changed

7 files changed

+643
-175
lines changed
Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,102 @@
11
import type { NextRequest } from "next/server";
2+
import { z } from "zod";
23

34
import { getHistoricalPrices } from "../../services/clickhouse";
45

6+
const queryParamsSchema = z.object({
7+
symbol: z.string().transform(decodeURIComponent),
8+
publisher: z
9+
.string()
10+
.nullable()
11+
.transform((value) => value ?? undefined),
12+
cluster: z.enum(["pythnet", "pythtest"]),
13+
from: z.string().transform(Number),
14+
to: z.string().transform(Number),
15+
resolution: z.enum(["1s", "1m", "5m", "1H", "1D"]).transform((value) => {
16+
switch (value) {
17+
case "1s": {
18+
return "1 SECOND";
19+
}
20+
case "1m": {
21+
return "1 MINUTE";
22+
}
23+
case "5m": {
24+
return "5 MINUTE";
25+
}
26+
case "1H": {
27+
return "1 HOUR";
28+
}
29+
case "1D": {
30+
return "1 DAY";
31+
}
32+
}
33+
}),
34+
});
35+
536
export async function GET(req: NextRequest) {
6-
const symbol = req.nextUrl.searchParams.get("symbol");
7-
const until = req.nextUrl.searchParams.get("until");
8-
if (symbol && until) {
9-
const res = await getHistoricalPrices({
10-
symbol: decodeURIComponent(symbol),
11-
until,
37+
const parsed = queryParamsSchema.safeParse(
38+
Object.fromEntries(
39+
Object.keys(queryParamsSchema.shape).map((key) => [
40+
key,
41+
req.nextUrl.searchParams.get(key),
42+
]),
43+
),
44+
);
45+
if (!parsed.success) {
46+
return new Response(`Invalid params: ${parsed.error.message}`, {
47+
status: 400,
48+
});
49+
}
50+
51+
const { symbol, publisher, cluster, from, to, resolution } = parsed.data;
52+
53+
try {
54+
checkMaxDataPointsInvariant(from, to, resolution);
55+
} catch {
56+
return new Response("Unsupported resolution for date range", {
57+
status: 400,
1258
});
13-
return Response.json(res);
14-
} else {
15-
return new Response("Must provide `symbol` and `until`", { status: 400 });
59+
}
60+
61+
const res = await getHistoricalPrices({
62+
symbol,
63+
from,
64+
to,
65+
publisher,
66+
cluster,
67+
resolution,
68+
});
69+
70+
return Response.json(res);
71+
}
72+
73+
const MAX_DATA_POINTS = 3000;
74+
function checkMaxDataPointsInvariant(
75+
from: number,
76+
to: number,
77+
resolution: "1 SECOND" | "1 MINUTE" | "5 MINUTE" | "1 HOUR" | "1 DAY",
78+
) {
79+
let diff = to - from;
80+
switch (resolution) {
81+
case "1 MINUTE": {
82+
diff = diff / 60;
83+
break;
84+
}
85+
case "5 MINUTE": {
86+
diff = diff / 60 / 5;
87+
break;
88+
}
89+
case "1 HOUR": {
90+
diff = diff / 3600;
91+
break;
92+
}
93+
case "1 DAY": {
94+
diff = diff / 86_400;
95+
break;
96+
}
97+
}
98+
99+
if (diff > MAX_DATA_POINTS) {
100+
throw new Error("Unsupported resolution for date range");
16101
}
17102
}

apps/insights/src/components/PriceFeed/Chart/chart-page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Spinner } from "@pythnetwork/component-library/Spinner";
66
import { Chart } from "./chart";
77
import styles from "./chart-page.module.scss";
88
import { getFeed } from "../get-feed";
9+
import { ChartToolbar } from "./chart-toolbar";
910

1011
type Props = {
1112
params: Promise<{
@@ -26,7 +27,7 @@ type ChartPageImplProps =
2627
});
2728

2829
const ChartPageImpl = (props: ChartPageImplProps) => (
29-
<Card title="Chart" className={styles.chartCard}>
30+
<Card title="Chart" className={styles.chartCard} toolbar={<ChartToolbar />}>
3031
<div className={styles.chart}>
3132
{props.isLoading ? (
3233
<div className={styles.spinnerContainer}>
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"use client";
2+
import { Select } from "@pythnetwork/component-library/Select";
3+
import { SingleToggleGroup } from "@pythnetwork/component-library/SingleToggleGroup";
4+
import { useLogger } from "@pythnetwork/component-library/useLogger";
5+
import { useCallback } from "react";
6+
import type { Key } from "react-aria";
7+
8+
import type { Lookback, Resolution } from "./use-chart-toolbar";
9+
import {
10+
LOOKBACK_TO_RESOLUTION,
11+
LOOKBACKS,
12+
RESOLUTION_TO_LOOKBACK,
13+
RESOLUTIONS,
14+
useChartLookback,
15+
useChartResolution,
16+
} from "./use-chart-toolbar";
17+
18+
export const ChartToolbar = () => {
19+
const logger = useLogger();
20+
const [lookback, setLookback] = useChartLookback();
21+
const [resolution, setResolution] = useChartResolution();
22+
23+
const handleLookbackChange = useCallback(
24+
(newValue: Key) => {
25+
if (!isLookback(newValue)) {
26+
throw new TypeError("Invalid lookback");
27+
}
28+
const lookback: Lookback = newValue;
29+
setLookback(lookback).catch((error: unknown) => {
30+
logger.error("Failed to update lookback", error);
31+
});
32+
setResolution(LOOKBACK_TO_RESOLUTION[lookback]).catch(
33+
(error: unknown) => {
34+
logger.error("Failed to update resolution", error);
35+
},
36+
);
37+
},
38+
[logger, setLookback, setResolution],
39+
);
40+
41+
const handleResolutionChanged = useCallback(
42+
(newValue: Key) => {
43+
if (!isResolution(newValue)) {
44+
throw new TypeError("Invalid resolution");
45+
}
46+
const resolution: Resolution = newValue;
47+
setResolution(resolution).catch((error: unknown) => {
48+
logger.error("Failed to update resolution", error);
49+
});
50+
setLookback(RESOLUTION_TO_LOOKBACK[resolution]).catch(
51+
(error: unknown) => {
52+
logger.error("Failed to update lookback", error);
53+
},
54+
);
55+
},
56+
[logger, setResolution, setLookback],
57+
);
58+
59+
return (
60+
<>
61+
<Select
62+
label="Resolution"
63+
hideLabel={true}
64+
options={RESOLUTIONS.map((resolution) => ({
65+
id: resolution,
66+
label: resolution,
67+
}))}
68+
selectedKey={resolution}
69+
onSelectionChange={handleResolutionChanged}
70+
size="sm"
71+
variant="outline"
72+
/>
73+
<SingleToggleGroup
74+
selectedKey={lookback}
75+
onSelectionChange={handleLookbackChange}
76+
rounded
77+
items={[
78+
{ id: "1m", children: "1m" },
79+
{ id: "1H", children: "1H" },
80+
{ id: "1D", children: "1D" },
81+
{ id: "1W", children: "1W" },
82+
{ id: "1M", children: "1M" },
83+
]}
84+
/>
85+
</>
86+
);
87+
};
88+
89+
function isLookback(value: Key): value is Lookback {
90+
return LOOKBACKS.includes(value as Lookback);
91+
}
92+
93+
function isResolution(value: Key): value is Resolution {
94+
return RESOLUTIONS.includes(value as Resolution);
95+
}
Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
@use "@pythnetwork/component-library/theme";
22

33
.chart {
4-
--border-light: #{theme.pallette-color("stone", 300)};
5-
--border-dark: #{theme.pallette-color("steel", 600)};
4+
--chart-background-light: #{theme.pallette-color("white")};
5+
--chart-background-dark: #{theme.pallette-color("steel", 900)};
6+
--border-light: #{theme.pallette-color("stone", 100)};
7+
--border-dark: #{theme.pallette-color("steel", 900)};
68
--muted-light: #{theme.pallette-color("stone", 700)};
79
--muted-dark: #{theme.pallette-color("steel", 300)};
810
--chart-series-primary-light: #{theme.pallette-color("violet", 500)};
911
--chart-series-primary-dark: #{theme.pallette-color("violet", 400)};
1012
--chart-series-neutral-light: #{theme.pallette-color("stone", 500)};
1113
--chart-series-neutral-dark: #{theme.pallette-color("steel", 300)};
14+
--chart-series-muted-light: #{theme.pallette-color("violet", 100)};
15+
--chart-series-muted-dark: #{theme.pallette-color("violet", 950)};
1216
}

0 commit comments

Comments
 (0)