Building an Interactive Crypto Trading Chart with React and TypeScript

Building an Interactive Crypto Trading Chart with React and TypeScript

January 4, 2025

12 min read

Building an Interactive Crypto Trading Chart with React and TypeScript

Building an Interactive Crypto Trading History Chart with React

Ever wondered how to visualize your cryptocurrency trading history alongside market prices? In this tutorial, we'll build an interactive chart that overlays your Ethereum trade history on price data using React and TypeScript. We'll leverage RadzionKit, a TypeScript monorepo boilerplate, to jumpstart development with pre-built utilities and components. Follow along with the complete source code available in this repository.

Trading history
Trading history

Defining the Trade Interface

Our application serves as a unified dashboard for tracking Ethereum trading activities across multiple EVM-compatible blockchains and wallet addresses. At its core, we define a trade using a TypeScript interface with these essential properties:

  • amount: The quantity of cryptocurrency involved in the transaction.
  • asset: The traded cryptocurrency (either ETH or Wrapped ETH).
  • cashAsset: The stablecoin used for payment (USDC or USDT).
  • price: The exchange rate at the time of execution.
  • type: The direction of the trade (buy or sell).
  • timestamp: The exact time when the transaction occurred.
  • hash: The unique transaction identifier on the blockchain.
import { TradeType } from "@lib/chain/types/TradeType"

export const tradeAssets = ["ETH", "WETH"] as const
export type TradeAsset = (typeof tradeAssets)[number]

export const cashAssets = ["USDC", "USDT"] as const
export type CashAsset = (typeof cashAssets)[number]

export const primaryTradeAssetPriceProviderId = "ethereum"

export type Trade = {
  amount: number
  asset: TradeAsset
  cashAsset: CashAsset
  price: number
  type: TradeType

  timestamp: number
  hash: string
}

Setting Up the Chart Component

Building on my previous tutorial about creating a trading history tracker (check it out here), we'll now dive into the visualization aspect. Our focus will be on crafting an interactive price chart with trade overlay capabilities. The journey begins with the TradesChart component, which takes an array of trades as input and transforms this data into an insightful visual representation.

import { MatchQuery } from "@lib/ui/query/components/MatchQuery"
import { useAssetTimeseriesQuery } from "@lib/chain-ui/queries/useAssetTimeseriesQuery"
import {
  Trade,
  primaryTradeAssetPriceProviderId,
} from "../../../entities/Trade"
import { useMemo } from "react"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { TradesChartContent } from "./TradesChartContent"

export type TradesChartProps = {
  trades: Trade[]
}

export function TradesChart({ trades }: TradesChartProps) {
  const days = useMemo(() => {
    const firstTradeAt = Math.min(...trades.map(({ timestamp }) => timestamp))
    return Math.ceil(convertDuration(Date.now() - firstTradeAt, "ms", "d"))
  }, [trades])

  const timeseriesQuery = useAssetTimeseriesQuery({
    id: primaryTradeAssetPriceProviderId,
    days,
  })

  return (
    <MatchQuery
      value={timeseriesQuery}
      success={(timeseries) => {
        if (timeseries.length < 2) return null
        return <TradesChartContent trades={trades} timeseries={timeseries} />
      }}
    />
  )
}

Fetching Historical Price Data

To create our price chart, we first need historical price data for Ethereum. We accomplish this using the useAssetTimeseriesQuery hook, which fetches time-series data based on two parameters: the asset ID and the desired time range in days. The time range is dynamically calculated by finding the timestamp of the earliest trade and determining how many days have elapsed until the present moment. This ensures we have sufficient historical data to display all trades on the chart.

import { useQuery } from "@tanstack/react-query"
import { getAssetTimeseries } from "../../chain/price/utils/getAssetTimeseries"
import { GetAssetTimeseriesInput } from "../../chain/price/utils/getAssetTimeseries"

export const useAssetTimeseriesQuery = (input: GetAssetTimeseriesInput) => {
  return useQuery({
    queryKey: ["asset-timeseries", input],
    queryFn: () => getAssetTimeseries(input),
  })
}

For retrieving historical price data, we leverage CoinGecko's market chart API endpoint. The getAssetTimeseries function is designed to be flexible and user-friendly, accepting three key parameters: an asset ID (such as "ethereum"), a fiat currency for price denomination (USD by default), and a time range in days (defaulting to 365 days). Under the hood, the function crafts an API request URL, fetches the data, and elegantly transforms CoinGecko's response into a clean, standardized format of timestamp-value pairs. This transformation step is crucial as it provides us with a consistent data structure that seamlessly integrates with our charting components.

import { addQueryParams } from "@lib/utils/query/addQueryParams"
import { FiatCurrency } from "../FiatCurrency"
import { queryUrl } from "@lib/utils/query/queryUrl"
import { TimePoint } from "@lib/utils/entities/TimePoint"

export type GetAssetTimeseriesInput = {
  id: string
  fiatCurrency?: FiatCurrency
  days?: number
}

type Response = {
  prices: [number, number][] // [timestamp, price]
}

const baseUrl = "https://api.coingecko.com/api/v3/coins"

export const getAssetTimeseries = async ({
  id,
  fiatCurrency = "usd",
  days = 365,
}: GetAssetTimeseriesInput): Promise<TimePoint[]> => {
  const url = addQueryParams(`${baseUrl}/${id}/market_chart`, {
    vs_currency: fiatCurrency,
    days: days.toString(),
  })

  const { prices } = await queryUrl<Response>(url)

  return prices.map(([timestamp, value]) => ({
    timestamp,
    value,
  }))
}

Managing Query States

For handling different states of our data fetching process, we utilize the MatchQuery component from RadzionKit. This versatile component enables us to elegantly manage the rendering lifecycle of our chart, displaying appropriate UI elements based on whether the data is loading, has successfully loaded, encountered an error, or is inactive. This pattern ensures a smooth user experience by preventing any unwanted flashes of content or error states.

import { ReactNode } from "react"

import { ComponentWithValueProps } from "../../props"
import { Query } from "../Query"

export type MatchQueryProps<T, E = unknown> = ComponentWithValueProps<
  Query<T, E>
> & {
  error?: (error: E) => ReactNode
  pending?: () => ReactNode
  success?: (data: T) => ReactNode
  inactive?: () => ReactNode
}

export function MatchQuery<T, E = unknown>({
  value,
  error = () => null,
  pending = () => null,
  success = () => null,
  inactive = () => null,
}: MatchQueryProps<T, E>) {
  if (value.data !== undefined) {
    return <>{success(value.data)}</>
  }

  if (value.error) {
    return <>{error(value.error)}</>
  }

  if (value.isLoading === false) {
    return <>{inactive()}</>
  }

  if (value.isPending) {
    return <>{pending()}</>
  }

  return null
}

export type MatchQueryWrapperProps<T, E = unknown> = Pick<
  MatchQueryProps<T>,
  "success"
> &
  Partial<Pick<MatchQueryProps<T, E>, "error" | "pending" | "inactive">>

Rendering the Chart

Now that we have our trades and timeseries data prepared, we can render the chart using RadzionKit's specialized charting components. While we'll use several components to create our visualization, we won't delve into their individual implementations here. If you're interested in understanding the underlying mechanics of these charting components, I've written a comprehensive tutorial on building a line chart from scratch, which you can find here.

import { Trade } from "../../../entities/Trade"
import { TimePoint } from "@lib/utils/entities/TimePoint"
import { ElementSizeAware } from "@lib/ui/base/ElementSizeAware"
import { VStack } from "@lib/ui/css/stack"
import { normalizeDataArrays } from "@lib/utils/math/normalizeDataArrays"
import { generateYLabels } from "@lib/ui/charts/utils/generateYLabels"
import { ChartYAxis } from "@lib/ui/charts/ChartYAxis"
import { tradesChartConfig } from "./config"
import { useTheme } from "styled-components"
import { ChartHorizontalGridLines } from "@lib/ui/charts/ChartHorizontalGridLines"
import { LineChart } from "@lib/ui/charts/LineChart"
import { TradePoint } from "./TradePoint"
import { toPercents } from "@lib/utils/toPercents"
import { getLastItem } from "@lib/utils/array/getLastItem"
import { getOppositeTrade } from "@lib/chain/utils/getOppositeTrade"
import { isGoodPrice } from "../../utils/isGoodPrice"
import { getIntervalDuration } from "@lib/utils/interval/getIntervalDuration"
import { ChartXAxis } from "@lib/ui/charts/ChartXAxis"
import { format } from "date-fns"
import { ChartLabel } from "./ChartLabel"
import { ChartSlice } from "./ChartSlice"

interface TradesChartContentProps {
  trades: Trade[]
  timeseries: TimePoint[]
}

export function TradesChartContent({
  trades,
  timeseries,
}: TradesChartContentProps) {
  const { colors } = useTheme()
  const prices = timeseries.map((timepoint) => timepoint.value)
  const yLabels = generateYLabels({ data: prices })

  const normalized = normalizeDataArrays({
    trades: trades.map((trade) => trade.price),
    prices: timeseries.map((timepoint) => timepoint.value),
    yLabels,
  })

  const color = isGoodPrice({
    trades,
    tradeType: getOppositeTrade(trades[0].type),
    price: getLastItem(timeseries).value,
  })
    ? colors.success
    : colors.alert

  const timeInterval = {
    start: timeseries[0].timestamp,
    end: getLastItem(timeseries).timestamp,
  }

  return (
    <ElementSizeAware
      render={({ setElement, size }) => {
        const contentWidth = size
          ? size.width - tradesChartConfig.yLabelsWidth
          : undefined

        return (
          <VStack gap={20} ref={setElement}>
            <ChartSlice>
              <ChartYAxis
                renderLabel={(index) => (
                  <ChartLabel key={index}>{yLabels[index]}</ChartLabel>
                )}
                data={normalized.yLabels}
              />
              <VStack
                style={{
                  position: "relative",
                }}
                fullWidth
              >
                {contentWidth && (
                  <LineChart
                    dataPointsConnectionKind="sharp"
                    fillKind={"gradient"}
                    data={normalized.prices}
                    width={contentWidth}
                    height={tradesChartConfig.chartHeight}
                    color={color}
                  />
                )}
                <ChartHorizontalGridLines data={normalized.yLabels} />
                {trades.map((trade, index) => (
                  <TradePoint
                    key={trade.hash}
                    style={{
                      left: toPercents(
                        (trade.timestamp - timeInterval.start) /
                          getIntervalDuration(timeInterval),
                      ),
                      top: toPercents(1 - normalized.trades[index]),
                    }}
                    value={trade}
                  />
                ))}
              </VStack>
            </ChartSlice>
            <ChartSlice>
              <div />
              {contentWidth && (
                <ChartXAxis
                  dataSize={timeseries.length}
                  containerWidth={contentWidth}
                  expectedLabelHeight={tradesChartConfig.xLabelsHeight}
                  expectedLabelWidth={tradesChartConfig.xLabelsWidth}
                  labelsMinDistance={tradesChartConfig.xLabelsMinDistance}
                  renderLabel={(index) => (
                    <ChartLabel key={index}>
                      {format(timeseries[index].timestamp, "MMM yyyy")}
                    </ChartLabel>
                  )}
                />
              )}
            </ChartSlice>
          </VStack>
        )
      }}
    />
  )
}

Generating Y-Axis Labels

The generateYLabels function calculates optimal Y-axis labels for our Ethereum price chart. It intelligently determines step sizes between labels to create a clean, readable scale while avoiding overcrowded or sparse label distribution.

import { range } from "@lib/utils/array/range"

type Input = {
  data: number[]
  minLabels?: number
  maxLabels?: number
}

const PREFERRED_STEPS = [1, 2, 2.5, 5, 10] as const

const getMagnitude = (value: number): number => {
  if (value === 0) return 1
  return Math.pow(10, Math.floor(Math.log10(Math.abs(value))))
}

const getBoundaryValue = (
  value: number,
  step: number,
  roundUp: boolean,
): number => {
  return (roundUp ? Math.ceil(value / step) : Math.floor(value / step)) * step
}

const getStepSizes = (approximateStep: number): number[] => {
  const magnitude = getMagnitude(approximateStep)
  return PREFERRED_STEPS.map((step) => step * magnitude)
}

const findBestStep = (
  dataRange: number,
  stepSizes: number[],
  minLabels: number,
  maxLabels: number,
): number => {
  if (dataRange === 0) {
    return stepSizes[0]
  }

  for (const step of stepSizes) {
    const labelCount = Math.floor(dataRange / step) + 1
    if (labelCount >= minLabels && labelCount <= maxLabels) {
      return step
    }
  }
  return stepSizes[0]
}

export const generateYLabels = ({
  data,
  minLabels = 4,
  maxLabels = 6,
}: Input): number[] => {
  if (data.length === 0) {
    return []
  }

  const maxValue = Math.max(...data)
  const minValue = Math.min(...data)
  const dataRange = maxValue - minValue

  if (dataRange === 0) {
    const magnitude = getMagnitude(Math.abs(maxValue))
    const step = magnitude / 2
    const base = getBoundaryValue(maxValue, step, false)
    return range(5).map((i) => Number((base + (i - 2) * step).toFixed(2)))
  }

  const approximateStep = dataRange / (maxLabels - 1)
  const stepSizes = getStepSizes(approximateStep)
  const bestStep = findBestStep(dataRange, stepSizes, minLabels, maxLabels)

  const lowerBound = getBoundaryValue(minValue, bestStep, false)
  const upperBound = getBoundaryValue(maxValue, bestStep, true)
  const labelCount = Math.floor((upperBound - lowerBound) / bestStep) + 1

  return range(labelCount).map((i) =>
    Number((lowerBound + i * bestStep).toFixed(2)),
  )
}

Normalizing Data for Visualization

To ensure consistent scaling across our visualization, we normalize all values on the Y-axis to a range between 0 and 1. This normalization process is handled by the normalizeDataArrays function, which takes our trade prices, y-axis labels, and timeseries prices as input. The function identifies the global minimum and maximum values across all arrays, then transforms each value proportionally within the 0-1 range, maintaining the relative relationships between data points while providing a standardized scale for rendering.

export const normalizeDataArrays = <T extends Record<string, number[]>>(
  input: T,
): T => {
  const values = Object.values(input).flat()
  const max = Math.max(...values)
  const min = Math.min(...values)
  const range = max - min
  return Object.fromEntries(
    Object.entries(input).map(([key, value]) => [
      key,
      value.map((v) => (v - min) / range),
    ]),
  ) as T
}

Adding Interactive Trade Points

To overlay trade data on the price chart, we implement the TradePoint component. This component renders each trade as a clickable marker positioned absolutely on the chart, using distinct colors and icons to differentiate between buy and sell transactions. Buy trades are indicated by a plus icon in the background color, while sell trades display a minus icon in the primary color, making it easy to identify trade types at a glance.

import { UIComponentProps } from "@lib/ui/props"
import { MinusIcon } from "@lib/ui/icons/MinusIcon"
import { PlusIcon } from "@lib/ui/icons/PlusIcon"
import styled from "styled-components"
import { matchColor } from "@lib/ui/theme/getters"
import { centerContent } from "@lib/ui/css/centerContent"
import { sameDimensions } from "@lib/ui/css/sameDimensions"
import { round } from "@lib/ui/css/round"
import { Trade } from "../../../entities/Trade"
import { Tooltip } from "@lib/ui/tooltips/Tooltip"
import { TradeDetails } from "./TradeDetails"

type TradePointProps = UIComponentProps & {
  value: Trade
}

const IconContainer = styled.div<{ type: "buy" | "sell" }>`
  position: absolute;
  transform: translate(-50%, -50%);
  cursor: pointer;

  ${centerContent};
  ${sameDimensions(20)};
  font-size: 12px;
  ${round};
  background: ${matchColor("type", {
    buy: "background",
    sell: "primary",
  })};
  color: ${matchColor("type", {
    buy: "contrast",
    sell: "contrast",
  })};
  border: 1px solid;
`

export function TradePoint({ value, style, className }: TradePointProps) {
  const { type } = value

  return (
    <Tooltip
      content={<TradeDetails trade={value} />}
      renderOpener={(props) => (
        <IconContainer
          {...props}
          type={type}
          style={style}
          className={className}
        >
          {type === "buy" ? <PlusIcon /> : <MinusIcon />}
        </IconContainer>
      )}
    />
  )
}

Displaying Trade Details

When users hover over a trade point, we leverage RadzionKit's Tooltip component to reveal detailed trade information in an elegant overlay. This interactive element enhances the user experience by providing quick access to important transaction details without cluttering the main chart interface.

import { format } from "date-fns"
import styled from "styled-components"
import { Text } from "@lib/ui/text"
import { VStack } from "@lib/ui/css/stack"
import { hStack } from "@lib/ui/css/stack"
import { getColor } from "@lib/ui/theme/getters"
import { Trade } from "../../../entities/Trade"

const Field = styled.div`
  ${hStack({
    justifyContent: "space-between",
    fullWidth: true,
    alignItems: "center",
    gap: 20,
  })};

  min-width: 200px;

  > :first-child {
    color: ${getColor("textSupporting")};
  }
`

type TradeDetailsProps = {
  trade: Trade
}

export function TradeDetails({ trade }: TradeDetailsProps) {
  const { type, price, timestamp, amount, asset, cashAsset } = trade

  return (
    <VStack gap={4}>
      <Field>
        <Text>Type</Text>
        <Text>{type.toUpperCase()}</Text>
      </Field>
      <Field>
        <Text>Amount</Text>
        <Text>
          {amount.toFixed(2)} {asset}
        </Text>
      </Field>
      <Field>
        <Text>Price</Text>
        <Text>
          {price.toFixed(2)} {cashAsset}
        </Text>
      </Field>
      <Field>
        <Text>Date</Text>
        <Text>{format(timestamp, "dd MMM yyyy")}</Text>
      </Field>
    </VStack>
  )
}

Conclusion

In this tutorial, we've explored how to build an interactive trade chart visualization that combines price history with trade execution data. By leveraging RadzionKit's components and following clean architecture principles, we've created a robust and maintainable solution that can handle different states of data loading and display complex financial information in an intuitive way. The resulting chart not only helps users understand their trading history but also provides valuable context about market conditions at the time of each trade. Whether you're building a trading platform or any other data-rich application, the patterns and components we've discussed can serve as a solid foundation for creating sophisticated visualizations.