Skip to content

Commit

Permalink
[ECO-1777] Add charting library to frontend (#93)
Browse files Browse the repository at this point in the history
Co-authored-by: matt <[email protected]>
  • Loading branch information
xbtmatt and matt authored Jun 11, 2024
1 parent 99fd3f0 commit ccf6531
Show file tree
Hide file tree
Showing 41 changed files with 1,026 additions and 128 deletions.
3 changes: 2 additions & 1 deletion src/typescript/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@
"pre-commit": "pnpm run pre-commit:install && pnpm run pre-commit:run",
"pre-commit:install": "pre-commit install -c ../../../cfg/pre-commit-config.yaml",
"pre-commit:run": "pre-commit run --all-files -c ../../../cfg/pre-commit-config.yaml",
"start": "next start --port 3001"
"start": "next start --port 3001",
"vercel-install": "./vercel-submodule.sh && pnpm i"
},
"version": "0.0.1"
}
13 changes: 13 additions & 0 deletions src/typescript/frontend/public/styles/tradingview.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.theme-dark:root {
--tv-color-platform-background: #000000;
--tv-color-pane-background: #000000;
--tv-color-toolbar-button-background-hover: #000000;
--tv-color-toolbar-button-background-expanded: #000000;
--tv-color-toolbar-button-background-active: #000000;
--tv-color-toolbar-button-text: #ffffff;
--tv-color-toolbar-divider-background: #565656;

--tv-color-popup-background: #000000;
--tv-color-popup-element-text: #ffffff;
--tv-color-popup-element-secondary-text: #565656;
}
2 changes: 2 additions & 0 deletions src/typescript/frontend/src/app/launch/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { REVALIDATION_TIME } from "lib/server-env";
import ClientLaunchEmojicoinPage from "../../components/pages/launch-emojicoin/ClientLaunchEmojicoinPage";

export const revalidate = REVALIDATION_TIME;
export const dynamic = "force-dynamic";

export default function LaunchEmojicoinPage() {
Expand Down
8 changes: 8 additions & 0 deletions src/typescript/frontend/src/app/market/[market]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import getInitialChatData from "lib/queries/initial/chats";
import { fetchLatestMarketState } from "lib/queries/initial/state";
import getInitialSwapData from "lib/queries/initial/swaps";
import getInitialCandlesticks from "lib/queries/initial/candlesticks";
import ClientEmojicoinPage from "components/pages/emojicoin/ClientEmojicoinPage";
// import fetchMarketData from "lib/queries/initial/market-data";
import EmojiNotFoundPage from "./not-found";
Expand All @@ -12,6 +13,11 @@ export const dynamic = "force-dynamic";
const CHAT_DATA_ROWS = 100;
const SWAP_DATA_ROWS = 100;

/**
* Our queries work with the marketID, but the URL uses the emoji bytes with a URL encoding.
* That is, if you paste the emoji 💅🏾 into the URL, it becomes %F0%9F%92%85%F0%9F%8F%BE.
* Whereas the actual emoji bytes are: 0xf09f9285f09f8fbe.
*/
type StaticParams = {
market: string;
};
Expand Down Expand Up @@ -45,11 +51,13 @@ const EmojicoinPage = async (params: EmojicoinPageProps) => {
const marketID = res.marketID.toString();
const chatData = await getInitialChatData({ marketID, maxTotalRows: CHAT_DATA_ROWS });
const swapData = await getInitialSwapData({ marketID, maxTotalRows: SWAP_DATA_ROWS });
const candlesticks = await getInitialCandlesticks(marketID);
return (
<ClientEmojicoinPage
data={{
swaps: swapData,
chats: chatData,
candlesticks,
...res,
}}
/>
Expand Down
2 changes: 1 addition & 1 deletion src/typescript/frontend/src/app/pools/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import ClientPoolsPage from "components/pages/pools/ClientPoolsPage";
import { REVALIDATION_TIME } from "lib/server-env";

export const revalidate = REVALIDATION_TIME;
export const dynamic = "auto";
export const dynamic = "force-dynamic";

export default function PoolsPage() {
return <ClientPoolsPage />;
Expand Down
28 changes: 28 additions & 0 deletions src/typescript/frontend/src/components/charts/ChartContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// cspell:word datafeeds
import Script from "next/script";
import { type ChartContainerProps } from "./types";
import dynamic from "next/dynamic";
import React from "react";

// TODO: Remove ssr? Only adding to avoid the annoying async client component error that takes up 70% of my console.
const Chart = dynamic(() => import("./PrivateChart"), { ssr: true });

export const ChartContainer = (props: Omit<ChartContainerProps, "isScriptReady">) => {
const [isScriptReady, setIsScriptReady] = React.useState(false);

return (
<>
{isScriptReady ? <Chart {...props} isScriptReady={isScriptReady} /> : <>Loading...</>}
<Script
src="/static/datafeeds/udf/dist/bundle.js"
strategy="lazyOnload"
onLoad={() => {
setIsScriptReady(true);
}}
/>
;
</>
);
};

export default ChartContainer;
279 changes: 279 additions & 0 deletions src/typescript/frontend/src/components/charts/PrivateChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
// cspell:word intraday
// cspell:word minmov
// cspell:word pricescale
import { useEffect, useMemo, useRef } from "react";

import {
DAY_TO_CANDLESTICK_RESOLUTION,
EXCHANGE_NAME,
MS_IN_ONE_DAY,
TV_CHARTING_LIBRARY_RESOLUTIONS,
WIDGET_OPTIONS,
} from "./const";
import {
type Bar,
type DatafeedConfiguration,
type IBasicDataFeed,
type IChartingLibraryWidget,
type LibrarySymbolInfo,
type ResolutionString,
type SearchSymbolResultItem,
type Timezone,
widget,
} from "@static/charting_library";
import { getClientTimezone } from "lib/chart-utils";
import { symbolBytesToEmojis } from "@econia-labs/emojicoin-sdk";
import { type ChartContainerProps } from "./types";
import { resolveToEmojiSymbol } from "@store/event-utils";
import { useEventStore } from "context/store-context";
import { useRouter } from "next/navigation";
import { ROUTES } from "router/routes";
import path from "path";
import { getEmptyGroupedCandlesticks } from "@sdk/queries/client-utils/candlestick";
import { emojisToName } from "lib/utils/emojis-to-name-or-symbol";

const configurationData: DatafeedConfiguration = {
supported_resolutions: TV_CHARTING_LIBRARY_RESOLUTIONS as ResolutionString[],
symbols_types: [
{
name: "crypto",
value: "crypto",
},
],
};

// The general approach here will be to use data fetched from the endpoint within the datafeed to populate the chart
// candlestick data. It will handle all of the data fetching possible until the very last candlestick, which is not
// provided by the endpoint.
// After that, we do not use the datafeed to fetch anymore, because mqtt will handle streaming the data through the
// websocket protocol to the datafeed websocket stream.
// If the user refreshes the page, we will fetch the data from the endpoint again, and then repeat the process.
// TODO: Figure out if this is inefficient and if there's a way to reconcile data retrieved from mqtt with the datafeed.

export const Chart = async (props: ChartContainerProps) => {
const getSymbolFromMarketID = useEventStore((s) => s.getSymbolFromMarketID);
const getMarketIDFromSymbol = useEventStore((s) => s.getMarketIDFromSymbol);
const marketMap = useEventStore((s) => s.getMarketIDs()); // TODO: See if these trigger state updates / re-renders?
const candlesticks = useEventStore(
(s) => s.getMarket(props.marketID)?.periodicStateEvents.events ?? getEmptyGroupedCandlesticks()
);
// const lastSwap = useEventStore((s) => s.getMarket(props.marketID)?.swapEvents.events.at(0));
const tvWidget = useRef<IChartingLibraryWidget>();
const ref = useRef<HTMLDivElement>(null);
const router = useRouter();
const symbol = props.symbol;

const datafeed: IBasicDataFeed = useMemo(
() => ({
onReady: (callback) => {
setTimeout(() => callback(configurationData));
},
searchSymbols: async (userInput, _exchange, _symbolType, onResultReadyCallback) => {
// const marketIDs = Array.from(marketMap.keys());
const data = Array.from(marketMap.entries()).map(([marketID, emojis]) => {
return {
marketID,
emojis: symbolBytesToEmojis(emojis),
};
});
// TODO: Consider storing this in state..? It depends if we need to reconstruct it elsewhere,
// and if it generally carries a high cost/complexity to compute.
// Could also combine the map and filter into a single reduce.
const symbols = data.reduce<SearchSymbolResultItem[]>((acc, { marketID, emojis: item }) => {
const symbol = {
description: `Market #${marketID}: ${item.symbol}`,
exchange: EXCHANGE_NAME,
full_name: `${EXCHANGE_NAME}:${emojisToName(item.emojis)}`,
symbol: item.symbol,
ticker: item.symbol,
type: "crypto",
};
if (
symbol.full_name.includes(userInput) ||
symbol.symbol.includes(userInput) ||
symbol.ticker.includes(userInput) ||
symbol.ticker.includes(resolveToEmojiSymbol({ userInput, getSymbolFromMarketID }) ?? "")
) {
acc.push(symbol);
}
return acc;
}, []);

onResultReadyCallback(symbols);
},
resolveSymbol: async (symbolName, onSymbolResolvedCallback) => {
const symbol =
resolveToEmojiSymbol({ userInput: symbolName, getSymbolFromMarketID }) ?? symbolName;
const resolvedMarketID = getMarketIDFromSymbol(symbol) ?? props.marketID;
if (props.marketID !== resolvedMarketID) {
const newRoute = path.join(ROUTES.market, resolvedMarketID.toString());
router.push(newRoute);
}

const symbolInfo: LibrarySymbolInfo = {
ticker: symbol,
name: symbol,
description: symbol,
pricescale: 100,
volume_precision: -Math.ceil(Math.log10(Number("0.00000100") * Number("100.00000000"))),
minmov: 1,
exchange: EXCHANGE_NAME,
listed_exchange: "",
session: "24x7",
has_empty_bars: true,
has_seconds: false,
has_intraday: true,
has_daily: true,
has_weekly_and_monthly: false,
timezone: getClientTimezone() as Timezone,
type: "crypto",
supported_resolutions: configurationData.supported_resolutions,
format: "price",
};

setTimeout(() => onSymbolResolvedCallback(symbolInfo), 0);
},
getBars: async (symbolInfo, resolution, periodParams, onHistoryCallback, onErrorCallback) => {
const { from, to } = periodParams;
try {
const resolutionEnum = DAY_TO_CANDLESTICK_RESOLUTION[resolution.toString()];

const data = candlesticks[resolutionEnum];

if (data.length < 1) {
onHistoryCallback([], {
noData: true,
});
return;
}

const bars: Bar[] = data.reduce((acc: Bar[], event) => {
const time = Number(event.periodicStateMetadata.emitTime / 1000n);
if (time >= from * 1000 && time <= to * 1000) {
acc.push({
time: time,
open: Number(event.open) * 1000,
high: Number(event.high) * 1000,
low: Number(event.low) * 1000,
close: Number(event.close) * 1000,
volume: Number(event.volumeQuote),
});
}
return acc;
}, []);

bars.sort((a, b) => a.time - b.time);

// Need to figure out a way to add this last bar if it's not in the data.
// Right now the whole chart rerenders...it actually does this even with
// the candlesticks.
// if (lastSwap) {
// bars.push({
// time: Number(lastSwap.time / 1000n),
// open: Number(lastSwap.avgExecutionPrice) * 1000,
// high: Number(lastSwap.avgExecutionPrice) * 1000,
// low: Number(lastSwap.avgExecutionPrice) * 1000,
// close: Number(lastSwap.avgExecutionPrice) * 1000,
// volume: 0,
// })
// }

console.warn(bars);

console.warn(`[getBars]: returned ${bars.length} bar(s)`);
onHistoryCallback(bars, {
noData: bars.length === 0,
});
} catch (e) {
if (e instanceof Error) {
console.warn("[getBars]: Get error", e);
onErrorCallback(e.message);
}
}
},
// This is the function we should be using. Get bars is for the initial data
// but subscribe bars will be for the live data.
subscribeBars: async (
_symbolInfo,
_resolution,
_onRealtimeCallback,
_subscribeUID,
_onResetCacheNeededCallback
) => {},
unsubscribeBars: async (_subscriberUID) => {},
}),
[symbol, props.marketID] // eslint-disable-line react-hooks/exhaustive-deps
);
useEffect(() => {
console.debug("data feed rerendered, this should generally not happen..?", datafeed);
}, [datafeed]);

useEffect(() => {
if (!ref.current) {
return;
}

tvWidget.current = new widget({
...WIDGET_OPTIONS,
symbol: symbol as string,
datafeed,
container: ref.current,
timezone: (Intl.DateTimeFormat().resolvedOptions().timeZone ?? "Etc/UTC") as Timezone,
});

tvWidget.current.onChartReady(() => {
const chart = tvWidget.current!.activeChart();
const now = new Date();
const startDaysAgo = 1;
const endDaysAgo = 0;
const startMilliseconds = now.getTime() - startDaysAgo * MS_IN_ONE_DAY;
const endMilliseconds = now.getTime() - endDaysAgo * MS_IN_ONE_DAY;
const startTimestamp = Math.floor(new Date(startMilliseconds).getTime()) / 1000;
const endTimestamp = Math.floor(new Date(endMilliseconds).getTime()) / 1000;

chart
.setVisibleRange({
from: startTimestamp,
to: endTimestamp,
})
.then(() => {
const options: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "numeric",
day: "numeric",
};
const from = new Date(startTimestamp * 1000);
const to = new Date(endTimestamp * 1000);
/* eslint-disable-next-line no-console */
console.debug(
"Visible range applied:",
`${from.toLocaleDateString("en-US", options)}`,
`- ${to.toLocaleDateString("en-US", options)}\n`
);
})
.catch((error) => {
console.error("Error applying visible range:", error);
});
});

return () => {
if (tvWidget.current != null) {
tvWidget.current.remove();
tvWidget.current = undefined;
}
};
}, [datafeed, symbol]); // eslint-disable-line react-hooks/exhaustive-deps

return (
<div className="relative w-full">
<div className="absolute left-0 top-0 flex h-full w-full animate-fadeIn items-center justify-center text-center font-roboto-mono text-sm font-light leading-6 text-neutral-500 opacity-0 delay-[2000]">
<div>
{"The device you're using isn't supported. 😔 Please try viewing on another device."}
</div>
</div>
<div ref={ref} className="relative h-full w-full"></div>
</div>
);
};

export default Chart;
Loading

0 comments on commit ccf6531

Please sign in to comment.