Browse Source

Merge pull request #2300 from pyth-network/cprussin/insights-landing

feat(insights): add overview page
Connor Prussin 10 months ago
parent
commit
c99f0f08e0

+ 31 - 141
apps/insights/src/components/ChangePercent/index.tsx

@@ -1,157 +1,47 @@
-"use client";
+import type { ComponentProps } from "react";
 
-import { type ComponentProps, createContext, use } from "react";
-import { useNumberFormatter } from "react-aria";
-import { z } from "zod";
-
-import { StateType, useData } from "../../use-data";
 import { ChangeValue } from "../ChangeValue";
-import { useLivePrice } from "../LivePrices";
-
-const ONE_SECOND_IN_MS = 1000;
-const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS;
-const ONE_HOUR_IN_MS = 60 * ONE_MINUTE_IN_MS;
-const REFRESH_YESTERDAYS_PRICES_INTERVAL = ONE_HOUR_IN_MS;
-
-type Props = Omit<ComponentProps<typeof YesterdaysPricesContext>, "value"> & {
-  feeds: Record<string, string>;
-};
-
-const YesterdaysPricesContext = createContext<
-  undefined | ReturnType<typeof useData<Map<string, number>>>
->(undefined);
-
-export const YesterdaysPricesProvider = ({ feeds, ...props }: Props) => {
-  const state = useData(
-    ["yesterdaysPrices", Object.keys(feeds)],
-    () => getYesterdaysPrices(feeds),
-    {
-      refreshInterval: REFRESH_YESTERDAYS_PRICES_INTERVAL,
-    },
-  );
-
-  return <YesterdaysPricesContext value={state} {...props} />;
-};
-
-const getYesterdaysPrices = async (
-  feeds: Props["feeds"],
-): Promise<Map<string, number>> => {
-  const url = new URL("/yesterdays-prices", window.location.origin);
-  for (const symbol of Object.keys(feeds)) {
-    url.searchParams.append("symbols", symbol);
-  }
-  const response = await fetch(url);
-  const data = yesterdaysPricesSchema.parse(await response.json());
-  return new Map(
-    Object.entries(data).map(([symbol, value]) => [feeds[symbol] ?? "", value]),
-  );
-};
-
-const yesterdaysPricesSchema = z.record(z.string(), z.number());
-
-const useYesterdaysPrices = () => {
-  const state = use(YesterdaysPricesContext);
-
-  if (state) {
-    return state;
-  } else {
-    throw new YesterdaysPricesNotInitializedError();
-  }
-};
-
-type ChangePercentProps = {
-  className?: string | undefined;
-  feedKey: string;
-};
-
-export const ChangePercent = ({ feedKey, className }: ChangePercentProps) => {
-  const yesterdaysPriceState = useYesterdaysPrices();
-
-  switch (yesterdaysPriceState.type) {
-    case StateType.Error:
-    case StateType.Loading:
-    case StateType.NotLoaded: {
-      return <ChangeValue className={className} isLoading />;
-    }
-
-    case StateType.Loaded: {
-      const yesterdaysPrice = yesterdaysPriceState.data.get(feedKey);
-      return yesterdaysPrice === undefined ? (
-        <ChangeValue className={className} isLoading />
-      ) : (
-        <ChangePercentLoaded
-          className={className}
-          priorPrice={yesterdaysPrice}
-          feedKey={feedKey}
-        />
-      );
-    }
-  }
-};
+import { FormattedNumber } from "../FormattedNumber";
 
-type ChangePercentLoadedProps = {
+type PriceDifferenceProps = Omit<
+  ComponentProps<typeof ChangeValue>,
+  "children" | "direction" | "isLoading"
+> & {
   className?: string | undefined;
-  priorPrice: number;
-  feedKey: string;
-};
-
-const ChangePercentLoaded = ({
-  className,
-  priorPrice,
-  feedKey,
-}: ChangePercentLoadedProps) => {
-  const { current } = useLivePrice(feedKey);
-
-  return current === undefined ? (
-    <ChangeValue className={className} isLoading />
-  ) : (
-    <PriceDifference
-      className={className}
-      currentPrice={current.aggregate.price}
-      priorPrice={priorPrice}
-    />
+} & (
+    | { isLoading: true }
+    | {
+        isLoading?: false;
+        currentValue: number;
+        previousValue: number;
+      }
   );
-};
-
-type PriceDifferenceProps = {
-  className?: string | undefined;
-  currentPrice: number;
-  priorPrice: number;
-};
 
-const PriceDifference = ({
-  className,
-  currentPrice,
-  priorPrice,
-}: PriceDifferenceProps) => {
-  const numberFormatter = useNumberFormatter({ maximumFractionDigits: 2 });
-  const direction = getDirection(currentPrice, priorPrice);
-
-  return (
-    <ChangeValue direction={direction} className={className}>
-      {numberFormatter.format(
-        (100 * Math.abs(currentPrice - priorPrice)) / priorPrice,
-      )}
+export const ChangePercent = ({ ...props }: PriceDifferenceProps) =>
+  props.isLoading ? (
+    <ChangeValue {...props} />
+  ) : (
+    <ChangeValue
+      direction={getDirection(props.currentValue, props.previousValue)}
+      {...props}
+    >
+      <FormattedNumber
+        maximumFractionDigits={2}
+        value={
+          (100 * Math.abs(props.currentValue - props.previousValue)) /
+          props.previousValue
+        }
+      />
       %
     </ChangeValue>
   );
-};
 
-const getDirection = (currentPrice: number, priorPrice: number) => {
-  if (currentPrice < priorPrice) {
+const getDirection = (currentValue: number, previousValue: number) => {
+  if (currentValue < previousValue) {
     return "down";
-  } else if (currentPrice > priorPrice) {
+  } else if (currentValue > previousValue) {
     return "up";
   } else {
     return "flat";
   }
 };
-
-class YesterdaysPricesNotInitializedError extends Error {
-  constructor() {
-    super(
-      "This component must be contained within a <YesterdaysPricesProvider>",
-    );
-    this.name = "YesterdaysPricesNotInitializedError";
-  }
-}

+ 13 - 0
apps/insights/src/components/ChartCard/index.module.scss

@@ -0,0 +1,13 @@
+@use "@pythnetwork/component-library/theme";
+
+.chartCard {
+  .line {
+    color: theme.color("chart", "series", "neutral");
+  }
+
+  &[data-variant="primary"] {
+    .line {
+      color: theme.color("chart", "series", "primary");
+    }
+  }
+}

+ 6 - 3
apps/insights/src/components/Publisher/chart-card.tsx → apps/insights/src/components/ChartCard/index.tsx

@@ -1,6 +1,7 @@
 "use client";
 
 import { StatCard } from "@pythnetwork/component-library/StatCard";
+import clsx from "clsx";
 import dynamic from "next/dynamic";
 import {
   type ElementType,
@@ -14,6 +15,8 @@ import {
 import { ResponsiveContainer, Tooltip, Line, XAxis, YAxis } from "recharts";
 import type { CategoricalChartState } from "recharts/types/chart/types";
 
+import styles from "./index.module.scss";
+
 const LineChart = dynamic(
   () => import("recharts").then((recharts) => recharts.LineChart),
   {
@@ -25,7 +28,6 @@ const CHART_HEIGHT = 36;
 
 type OwnProps<T> = {
   chartClassName?: string | undefined;
-  lineClassName?: string | undefined;
   data: Point<T>[];
 };
 
@@ -43,8 +45,8 @@ type Props<T extends ElementType, U> = Omit<
   OwnProps<U>;
 
 export const ChartCard = <T extends ElementType, U>({
+  className,
   chartClassName,
-  lineClassName,
   data,
   stat,
   miniStat,
@@ -77,6 +79,7 @@ export const ChartCard = <T extends ElementType, U>({
 
   return (
     <StatCard
+      className={clsx(className, styles.chartCard)}
       {...props}
       stat={selectedPoint ? (selectedPoint.displayY ?? selectedPoint.y) : stat}
       miniStat={selectedDate ?? miniStat}
@@ -96,7 +99,7 @@ export const ChartCard = <T extends ElementType, U>({
             <Line
               type="monotone"
               dataKey="y"
-              className={lineClassName ?? ""}
+              className={styles.line ?? ""}
               stroke="currentColor"
               dot={false}
             />

+ 54 - 0
apps/insights/src/components/Overview/index.module.scss

@@ -8,5 +8,59 @@
 
     color: theme.color("heading");
     font-weight: theme.font-weight("semibold");
+    margin-bottom: theme.spacing(6);
+  }
+
+  .stats {
+    display: flex;
+    flex-flow: row nowrap;
+    align-items: stretch;
+    gap: theme.spacing(6);
+
+    & > * {
+      flex: 1 1 0px;
+      width: 0;
+    }
+
+    .publishersChart,
+    .priceFeedsChart {
+      & svg {
+        cursor: pointer;
+      }
+    }
+  }
+
+  .overviewMainContent {
+    display: grid;
+    grid-template-columns: repeat(2, 1fr);
+    gap: theme.spacing(40);
+    align-items: center;
+    padding: theme.spacing(18) 0;
+
+    .headline {
+      @include theme.text("3xl", "medium");
+
+      color: theme.color("heading");
+      line-height: 125%;
+      margin-top: theme.spacing(8);
+      margin-bottom: theme.spacing(4);
+    }
+
+    .message {
+      @include theme.text("base", "normal");
+
+      color: theme.color("heading");
+      line-height: 150%;
+    }
+
+    .tabList {
+      margin: theme.spacing(12) 0;
+    }
+
+    .buttons {
+      display: flex;
+      flex-flow: row nowrap;
+      gap: theme.spacing(3);
+    }
   }
 }

+ 140 - 0
apps/insights/src/components/Overview/index.tsx

@@ -1,7 +1,147 @@
+import { Badge } from "@pythnetwork/component-library/Badge";
+import { Button } from "@pythnetwork/component-library/Button";
+import { CrossfadeTabPanels } from "@pythnetwork/component-library/CrossfadeTabPanels";
+import { Tabs } from "@pythnetwork/component-library/unstyled/Tabs";
+
 import styles from "./index.module.scss";
+import PriceFeeds from "./price-feeds.svg";
+import Publishers from "./publishers.svg";
+import { TabList } from "./tab-list";
+import {
+  totalVolumeTraded,
+  activeChains,
+  activePublishers,
+  activeFeeds,
+} from "../../static-data/stats";
+import { ChangePercent } from "../ChangePercent";
+import { ChartCard } from "../ChartCard";
+import { FormattedDate } from "../FormattedDate";
+import { FormattedNumber } from "../FormattedNumber";
 
 export const Overview = () => (
   <div className={styles.overview}>
     <h1 className={styles.header}>Overview</h1>
+    <section className={styles.stats}>
+      <ChartCard
+        header="Total Volume Traded"
+        variant="primary"
+        data={totalVolumeTraded.map(({ date, volume }) => ({
+          x: date,
+          displayX: <FormattedDate value={date} />,
+          y: volume,
+          displayY: (
+            <FormattedNumber
+              value={volume}
+              currency="usd"
+              style="currency"
+              notation="compact"
+            />
+          ),
+        }))}
+        miniStat={
+          <ChangePercent
+            previousValue={totalVolumeTraded.at(-2)?.volume ?? 0}
+            currentValue={totalVolumeTraded.at(-1)?.volume ?? 0}
+          />
+        }
+        stat={
+          <FormattedNumber
+            value={totalVolumeTraded.at(-1)?.volume ?? 0}
+            currency="usd"
+            style="currency"
+            notation="compact"
+          />
+        }
+      />
+      <ChartCard
+        header="Publishers Onboarded"
+        href="/publishers"
+        chartClassName={styles.publishersChart}
+        data={activePublishers.map(({ date, numPublishers }) => ({
+          x: date,
+          displayX: <FormattedDate value={date} />,
+          y: numPublishers,
+        }))}
+        miniStat={
+          <ChangePercent
+            previousValue={activePublishers.at(-2)?.numPublishers ?? 0}
+            currentValue={activePublishers.at(-1)?.numPublishers ?? 0}
+          />
+        }
+        stat={activePublishers.at(-1)?.numPublishers}
+      />
+      <ChartCard
+        header="Price Feeds (Active + Coming Soon)"
+        href="/price-feeds"
+        chartClassName={styles.priceFeedsChart}
+        data={activeFeeds.map(({ date, numFeeds }) => ({
+          x: date,
+          displayX: <FormattedDate value={date} />,
+          y: numFeeds,
+        }))}
+        miniStat={
+          <ChangePercent
+            previousValue={activeFeeds.at(-2)?.numFeeds ?? 0}
+            currentValue={activeFeeds.at(-1)?.numFeeds ?? 0}
+          />
+        }
+        stat={activeFeeds.at(-1)?.numFeeds}
+      />
+      <ChartCard
+        header="Active Chains"
+        data={activeChains.map(({ date, chains }) => ({
+          x: date,
+          displayX: <FormattedDate value={date} />,
+          y: chains,
+        }))}
+        miniStat={
+          <ChangePercent
+            previousValue={activeChains.at(-2)?.chains ?? 0}
+            currentValue={activeChains.at(-1)?.chains ?? 0}
+          />
+        }
+        stat={activeChains.at(-1)?.chains}
+      />
+    </section>
+    <Tabs orientation="vertical" className={styles.overviewMainContent ?? ""}>
+      <section>
+        <Badge>INSIGHTS</Badge>
+        <p className={styles.headline}>Get the most from the Pyth Network</p>
+        <p className={styles.message}>
+          Insights Hub delivers transparency over the network status and
+          performance, and maximize productivity while integrating.
+        </p>
+        <TabList
+          label="test"
+          className={styles.tabList ?? ""}
+          items={[
+            {
+              id: "publishers",
+              header: "Publishers",
+              body: "Get insights about quality, ranking, and performance of each Publisher contributing to the network.",
+            },
+            {
+              id: "price feeds",
+              header: "Price Feeds",
+              body: "See information about every price feed's price, performance, components, and technical aspects all in one place for a better integration experience.",
+            },
+          ]}
+        />
+        <div className={styles.buttons}>
+          <Button href="/publishers" variant="solid" size="md">
+            Publishers
+          </Button>
+          <Button href="/price-feeds" variant="outline" size="md">
+            Price Feeds
+          </Button>
+        </div>
+      </section>
+      <CrossfadeTabPanels
+        items={[
+          { id: "publishers", children: <Publishers /> },
+          { id: "price feeds", children: <PriceFeeds /> },
+        ]}
+      />
+    </Tabs>
   </div>
 );

File diff suppressed because it is too large
+ 13 - 0
apps/insights/src/components/Overview/price-feeds.svg


File diff suppressed because it is too large
+ 19 - 0
apps/insights/src/components/Overview/publishers.svg


+ 73 - 0
apps/insights/src/components/Overview/tab-list.module.scss

@@ -0,0 +1,73 @@
+@use "@pythnetwork/component-library/theme";
+
+.tabList {
+  display: flex;
+  flex-flow: column nowrap;
+  gap: theme.spacing(2);
+
+  .tab {
+    padding: theme.spacing(2) theme.spacing(6);
+    border-radius: theme.border-radius("lg");
+    display: flex;
+    flex-flow: column nowrap;
+    gap: theme.spacing(2);
+    transition-property: background-color, border-color, outline-color;
+    transition-duration: 100ms;
+    transition-timing-function: linear;
+    border: 1px solid transparent;
+    outline-offset: 0;
+    outline: theme.spacing(1) solid transparent;
+    position: relative;
+
+    &::before {
+      content: "";
+      background: theme.color("border");
+      position: absolute;
+      left: 0;
+      top: theme.border-radius("lg");
+      height: calc(100% - (2 * theme.border-radius("lg")));
+      width: 1px;
+    }
+
+    & > h2 {
+      @include theme.text("xl", "medium");
+
+      color: theme.color("heading");
+      line-height: normal;
+    }
+
+    & > p {
+      @include theme.text("sm", "normal");
+
+      color: theme.color("heading");
+      line-height: 140%;
+    }
+
+    & > .bar {
+      position: absolute;
+      left: 0;
+      top: theme.border-radius("lg");
+      height: calc(100% - (2 * theme.border-radius("lg")));
+      width: theme.spacing(0.75);
+      background: theme.color("foreground");
+    }
+
+    &[data-focus-visible] {
+      border-color: theme.color("focus");
+      outline-color: theme.color("focus-dim");
+    }
+
+    &:not([data-selected]) {
+      cursor: pointer;
+
+      &[data-hovered] {
+        background-color: theme.color(
+          "button",
+          "outline",
+          "background",
+          "hover"
+        );
+      }
+    }
+  }
+}

+ 49 - 0
apps/insights/src/components/Overview/tab-list.tsx

@@ -0,0 +1,49 @@
+"use client";
+
+import {
+  Tab,
+  TabList as UnstyledTabList,
+} from "@pythnetwork/component-library/unstyled/Tabs";
+import clsx from "clsx";
+import { motion } from "motion/react";
+import { type ComponentProps, useId } from "react";
+
+import styles from "./tab-list.module.scss";
+
+type OwnProps = {
+  label: string;
+  items: (ComponentProps<typeof Tab> & { header: string; body: string })[];
+};
+type Props = Omit<ComponentProps<typeof UnstyledTabList>, keyof OwnProps> &
+  OwnProps;
+
+export const TabList = ({ label, className, ...props }: Props) => {
+  const layoutId = useId();
+
+  return (
+    <UnstyledTabList
+      aria-label={label}
+      className={clsx(className, styles.tabList)}
+      {...props}
+    >
+      {({ header, body, className: tabClassName, ...tabProps }) => (
+        <Tab className={clsx(styles.tab, tabClassName)} {...tabProps}>
+          {(args) => (
+            <>
+              <h2>{header}</h2>
+              <p>{body}</p>
+              {args.isSelected && (
+                <motion.span
+                  layoutId={layoutId}
+                  className={styles.bar}
+                  transition={{ type: "spring", bounce: 0.6, duration: 0.6 }}
+                  style={{ originX: "left" }}
+                />
+              )}
+            </>
+          )}
+        </Tab>
+      )}
+    </UnstyledTabList>
+  );
+};

+ 5 - 2
apps/insights/src/components/PriceFeed/layout.tsx

@@ -15,7 +15,6 @@ import { PriceFeedSelect } from "./price-feed-select";
 import { ReferenceData } from "./reference-data";
 import { toHex } from "../../hex";
 import { Cluster, getData } from "../../services/pyth";
-import { YesterdaysPricesProvider, ChangePercent } from "../ChangePercent";
 import { FeedKey } from "../FeedKey";
 import {
   LivePrice,
@@ -23,6 +22,10 @@ import {
   LiveLastUpdated,
   LiveValue,
 } from "../LivePrices";
+import {
+  YesterdaysPricesProvider,
+  PriceFeedChangePercent,
+} from "../PriceFeedChangePercent";
 import { PriceFeedIcon } from "../PriceFeedIcon";
 import { PriceFeedTag } from "../PriceFeedTag";
 import { TabPanel, TabRoot, Tabs } from "../Tabs";
@@ -166,7 +169,7 @@ export const PriceFeedLayout = async ({ children, params }: Props) => {
               <YesterdaysPricesProvider
                 feeds={{ [feed.symbol]: feed.product.price_account }}
               >
-                <ChangePercent feedKey={feed.product.price_account} />
+                <PriceFeedChangePercent feedKey={feed.product.price_account} />
               </YesterdaysPricesProvider>
             }
           />

+ 128 - 0
apps/insights/src/components/PriceFeedChangePercent/index.tsx

@@ -0,0 +1,128 @@
+"use client";
+
+import { type ComponentProps, createContext, use } from "react";
+import { z } from "zod";
+
+import { StateType, useData } from "../../use-data";
+import { ChangePercent } from "../ChangePercent";
+import { useLivePrice } from "../LivePrices";
+
+const ONE_SECOND_IN_MS = 1000;
+const ONE_MINUTE_IN_MS = 60 * ONE_SECOND_IN_MS;
+const ONE_HOUR_IN_MS = 60 * ONE_MINUTE_IN_MS;
+const REFRESH_YESTERDAYS_PRICES_INTERVAL = ONE_HOUR_IN_MS;
+
+type YesterdaysPricesProviderProps = Omit<
+  ComponentProps<typeof YesterdaysPricesContext>,
+  "value"
+> & {
+  feeds: Record<string, string>;
+};
+
+const YesterdaysPricesContext = createContext<
+  undefined | ReturnType<typeof useData<Map<string, number>>>
+>(undefined);
+
+export const YesterdaysPricesProvider = ({
+  feeds,
+  ...props
+}: YesterdaysPricesProviderProps) => {
+  const state = useData(
+    ["yesterdaysPrices", Object.keys(feeds)],
+    () => getYesterdaysPrices(feeds),
+    {
+      refreshInterval: REFRESH_YESTERDAYS_PRICES_INTERVAL,
+    },
+  );
+
+  return <YesterdaysPricesContext value={state} {...props} />;
+};
+
+const getYesterdaysPrices = async (
+  feeds: YesterdaysPricesProviderProps["feeds"],
+): Promise<Map<string, number>> => {
+  const url = new URL("/yesterdays-prices", window.location.origin);
+  for (const symbol of Object.keys(feeds)) {
+    url.searchParams.append("symbols", symbol);
+  }
+  const response = await fetch(url);
+  const data = yesterdaysPricesSchema.parse(await response.json());
+  return new Map(
+    Object.entries(data).map(([symbol, value]) => [feeds[symbol] ?? "", value]),
+  );
+};
+
+const yesterdaysPricesSchema = z.record(z.string(), z.number());
+
+const useYesterdaysPrices = () => {
+  const state = use(YesterdaysPricesContext);
+
+  if (state) {
+    return state;
+  } else {
+    throw new YesterdaysPricesNotInitializedError();
+  }
+};
+
+type Props = {
+  className?: string | undefined;
+  feedKey: string;
+};
+
+export const PriceFeedChangePercent = ({ feedKey, className }: Props) => {
+  const yesterdaysPriceState = useYesterdaysPrices();
+
+  switch (yesterdaysPriceState.type) {
+    case StateType.Error:
+    case StateType.Loading:
+    case StateType.NotLoaded: {
+      return <ChangePercent className={className} isLoading />;
+    }
+
+    case StateType.Loaded: {
+      const yesterdaysPrice = yesterdaysPriceState.data.get(feedKey);
+      return yesterdaysPrice === undefined ? (
+        <ChangePercent className={className} isLoading />
+      ) : (
+        <PriceFeedChangePercentLoaded
+          className={className}
+          priorPrice={yesterdaysPrice}
+          feedKey={feedKey}
+        />
+      );
+    }
+  }
+};
+
+type PriceFeedChangePercentLoadedProps = {
+  className?: string | undefined;
+  priorPrice: number;
+  feedKey: string;
+};
+
+const PriceFeedChangePercentLoaded = ({
+  className,
+  priorPrice,
+  feedKey,
+}: PriceFeedChangePercentLoadedProps) => {
+  const { current } = useLivePrice(feedKey);
+
+  return current === undefined ? (
+    <ChangePercent className={className} isLoading />
+  ) : (
+    <ChangePercent
+      className={className}
+      currentValue={current.aggregate.price}
+      previousValue={priorPrice}
+    />
+  );
+};
+
+class YesterdaysPricesNotInitializedError extends Error {
+  constructor() {
+    super(
+      "This component must be contained within a <YesterdaysPricesProvider>",
+    );
+    this.name = "YesterdaysPricesNotInitializedError";
+  }
+}

+ 7 - 3
apps/insights/src/components/PriceFeeds/index.tsx

@@ -19,8 +19,12 @@ import styles from "./index.module.scss";
 import { PriceFeedsCard } from "./price-feeds-card";
 import { Cluster, getData } from "../../services/pyth";
 import { priceFeeds as priceFeedsStaticConfig } from "../../static-data/price-feeds";
-import { YesterdaysPricesProvider, ChangePercent } from "../ChangePercent";
+import { activeChains } from "../../static-data/stats";
 import { LivePrice } from "../LivePrices";
+import {
+  YesterdaysPricesProvider,
+  PriceFeedChangePercent,
+} from "../PriceFeedChangePercent";
 import { PriceFeedIcon } from "../PriceFeedIcon";
 import { PriceFeedTag } from "../PriceFeedTag";
 
@@ -62,7 +66,7 @@ export const PriceFeeds = async () => {
           />
           <StatCard
             header="Active Chains"
-            stat={priceFeedsStaticConfig.activeChains}
+            stat={activeChains.at(-1)?.chains}
             href="https://docs.pyth.network/price-feeds/contract-addresses"
             target="_blank"
             corner={<ArrowSquareOut weight="fill" />}
@@ -182,7 +186,7 @@ const FeaturedFeedsCard = <T extends ElementType>({
             {showPrices && (
               <div className={styles.prices}>
                 <LivePrice feedKey={feed.product.price_account} />
-                <ChangePercent
+                <PriceFeedChangePercent
                   className={styles.changePercent}
                   feedKey={feed.product.price_account}
                 />

+ 0 - 8
apps/insights/src/components/Publisher/layout.module.scss

@@ -39,14 +39,6 @@
         margin-right: -#{theme.button-padding("xs", false)};
       }
 
-      .primarySparkChartLine {
-        color: theme.color("chart", "series", "primary");
-      }
-
-      .secondarySparkChartLine {
-        color: theme.color("chart", "series", "neutral");
-      }
-
       .activeDate {
         color: theme.color("muted");
       }

+ 6 - 22
apps/insights/src/components/Publisher/layout.tsx

@@ -16,7 +16,6 @@ import { notFound } from "next/navigation";
 import type { ReactNode } from "react";
 
 import { ActiveFeedsCard } from "./active-feeds-card";
-import { ChartCard } from "./chart-card";
 import { getPriceFeeds } from "./get-price-feeds";
 import styles from "./layout.module.scss";
 import { OisApyHistory } from "./ois-apy-history";
@@ -29,7 +28,9 @@ import { getPublisherCaps } from "../../services/hermes";
 import { Cluster, getTotalFeedCount } from "../../services/pyth";
 import { getPublisherPoolData } from "../../services/staking";
 import { Status } from "../../status";
+import { ChangePercent } from "../ChangePercent";
 import { ChangeValue } from "../ChangeValue";
+import { ChartCard } from "../ChartCard";
 import { FormattedDate } from "../FormattedDate";
 import { FormattedNumber } from "../FormattedNumber";
 import { FormattedTokens } from "../FormattedTokens";
@@ -113,7 +114,6 @@ export const PublishersLayout = async ({ children, params }: Props) => {
             <ChartCard
               variant="primary"
               header="Publisher Ranking"
-              lineClassName={styles.primarySparkChartLine}
               corner={
                 <AlertTrigger>
                   <Button
@@ -162,7 +162,6 @@ export const PublishersLayout = async ({ children, params }: Props) => {
               <ChartCard
                 header="Median Score"
                 chartClassName={styles.medianScoreChart}
-                lineClassName={styles.secondarySparkChartLine}
                 corner={<Info weight="fill" />}
                 data={medianScoreHistory.map(({ time, score }) => ({
                   x: time,
@@ -187,25 +186,10 @@ export const PublishersLayout = async ({ children, params }: Props) => {
                 }
                 {...(previousMedianScore && {
                   miniStat: (
-                    <ChangeValue
-                      direction={getChangeDirection(
-                        previousMedianScore.score,
-                        currentMedianScore.score,
-                      )}
-                    >
-                      <FormattedNumber
-                        maximumSignificantDigits={2}
-                        value={
-                          (100 *
-                            Math.abs(
-                              currentMedianScore.score -
-                                previousMedianScore.score,
-                            )) /
-                          previousMedianScore.score
-                        }
-                      />
-                      %
-                    </ChangeValue>
+                    <ChangePercent
+                      currentValue={currentMedianScore.score}
+                      previousValue={previousMedianScore.score}
+                    />
                   ),
                 })}
               />

+ 0 - 1
apps/insights/src/static-data/price-feeds.tsx

@@ -1,5 +1,4 @@
 export const priceFeeds = {
-  activeChains: "88",
   updateFrequency: "400ms",
   featuredRecentlyAdded: [
     "Crypto.PYTH/USD",

+ 155 - 0
apps/insights/src/static-data/stats.tsx

@@ -0,0 +1,155 @@
+export const totalVolumeTraded = [
+  { date: new Date("30 Dec '21"), volume: 3_040_000_000 },
+  { date: new Date("30 Jan '22"), volume: 3_200_000_000 },
+  { date: new Date("27 Feb '22"), volume: 3_720_000_000 },
+  { date: new Date("30 Mar '22"), volume: 3_720_000_000 },
+  { date: new Date("29 Apr '22"), volume: 3_820_000_000 },
+  { date: new Date("30 May '22"), volume: 4_090_000_000 },
+  { date: new Date("29 Jun '22"), volume: 2_160_000_000 },
+  { date: new Date("30 Jul '22"), volume: 1_960_000_000 },
+  { date: new Date("30 Aug '22"), volume: 1_910_000_000 },
+  { date: new Date("29 Sep '22"), volume: 1_740_000_000 },
+  { date: new Date("30 Oct '22"), volume: 670_000_000 },
+  { date: new Date("29 Nov '22"), volume: 620_000_000 },
+  { date: new Date("30 Dec '22"), volume: 250_000_000 },
+  { date: new Date("30 Jan '23"), volume: 760_000_000 },
+  { date: new Date("27 Feb '23"), volume: 1_260_000_000 },
+  { date: new Date("30 Mar '23"), volume: 4_840_000_000 },
+  { date: new Date("29 Apr '23"), volume: 4_310_000_000 },
+  { date: new Date("30 May '23"), volume: 6_560_000_000 },
+  { date: new Date("29 Jun '23"), volume: 5_970_000_000 },
+  { date: new Date("30 Jul '23"), volume: 8_740_000_000 },
+  { date: new Date("30 Aug '23"), volume: 9_020_000_000 },
+  { date: new Date("29 Sep '23"), volume: 5_220_000_000 },
+  { date: new Date("30 Oct '23"), volume: 9_160_000_000 },
+  { date: new Date("29 Nov '23"), volume: 11_610_000_000 },
+  { date: new Date("30 Dec '23"), volume: 22_720_000_000 },
+  { date: new Date("30 Jan '24"), volume: 32_540_000_000 },
+  { date: new Date("28 Feb '24"), volume: 59_140_000_000 },
+  { date: new Date("30 Mar '24"), volume: 87_090_000_000 },
+  { date: new Date("29 Apr '24"), volume: 110_690_000_000 },
+  { date: new Date("30 May '24"), volume: 102_290_000_000 },
+  { date: new Date("29 Jun '24"), volume: 86_580_000_000 },
+  { date: new Date("30 Jul '24"), volume: 116_350_000_000 },
+  { date: new Date("30 Aug '24"), volume: 78_430_000_000 },
+  { date: new Date("29 Sep '24"), volume: 48_300_000_000 },
+  { date: new Date("30 Oct '24"), volume: 58_320_000_000 },
+  { date: new Date("29 Nov '24"), volume: 106_950_000_000 },
+  { date: new Date("30 Dec '24"), volume: 128_300_000_000 },
+];
+
+export const activeChains = [
+  { date: new Date("29 Sep '22"), chains: 1 },
+  { date: new Date("30 Oct '22"), chains: 3 },
+  { date: new Date("29 Nov '22"), chains: 8 },
+  { date: new Date("30 Dec '22"), chains: 10 },
+  { date: new Date("30 Jan '23"), chains: 11 },
+  { date: new Date("27 Feb '23"), chains: 13 },
+  { date: new Date("30 Mar '23"), chains: 17 },
+  { date: new Date("29 Apr '23"), chains: 21 },
+  { date: new Date("30 May '23"), chains: 25 },
+  { date: new Date("29 Jun '23"), chains: 26 },
+  { date: new Date("30 Jul '23"), chains: 30 },
+  { date: new Date("30 Aug '23"), chains: 32 },
+  { date: new Date("29 Sep '23"), chains: 35 },
+  { date: new Date("30 Oct '23"), chains: 40 },
+  { date: new Date("29 Nov '23"), chains: 45 },
+  { date: new Date("30 Dec '23"), chains: 49 },
+  { date: new Date("30 Jan '24"), chains: 51 },
+  { date: new Date("28 Feb '24"), chains: 53 },
+  { date: new Date("30 Mar '24"), chains: 56 },
+  { date: new Date("29 Apr '24"), chains: 58 },
+  { date: new Date("30 May '24"), chains: 64 },
+  { date: new Date("29 Jun '24"), chains: 70 },
+  { date: new Date("30 Jul '24"), chains: 74 },
+  { date: new Date("30 Aug '24"), chains: 76 },
+  { date: new Date("29 Sep '24"), chains: 78 },
+  { date: new Date("30 Oct '24"), chains: 84 },
+  { date: new Date("29 Nov '24"), chains: 88 },
+  { date: new Date("30 Dec '24"), chains: 93 },
+];
+
+export const activePublishers = [
+  { date: new Date("30 Jun '21"), numPublishers: 5 },
+  { date: new Date("31 Jul '21"), numPublishers: 9 },
+  { date: new Date("31 Aug '21"), numPublishers: 17 },
+  { date: new Date("30 Sep '21"), numPublishers: 22 },
+  { date: new Date("31 Oct '21"), numPublishers: 30 },
+  { date: new Date("30 Nov '21"), numPublishers: 34 },
+  { date: new Date("31 Dec '21"), numPublishers: 36 },
+  { date: new Date("31 Jan '22"), numPublishers: 36 },
+  { date: new Date("28 Feb '22"), numPublishers: 38 },
+  { date: new Date("31 Mar '22"), numPublishers: 45 },
+  { date: new Date("30 Apr '22"), numPublishers: 46 },
+  { date: new Date("31 May '22"), numPublishers: 48 },
+  { date: new Date("30 Jun '22"), numPublishers: 51 },
+  { date: new Date("31 Jul '22"), numPublishers: 55 },
+  { date: new Date("31 Aug '22"), numPublishers: 56 },
+  { date: new Date("30 Sep '22"), numPublishers: 62 },
+  { date: new Date("31 Oct '22"), numPublishers: 68 },
+  { date: new Date("30 Nov '22"), numPublishers: 69 },
+  { date: new Date("31 Dec '22"), numPublishers: 71 },
+  { date: new Date("31 Jan '23"), numPublishers: 72 },
+  { date: new Date("28 Feb '23"), numPublishers: 75 },
+  { date: new Date("31 Mar '23"), numPublishers: 76 },
+  { date: new Date("30 Apr '23"), numPublishers: 76 },
+  { date: new Date("31 May '23"), numPublishers: 77 },
+  { date: new Date("30 Jun '23"), numPublishers: 80 },
+  { date: new Date("31 Jul '23"), numPublishers: 82 },
+  { date: new Date("31 Aug '23"), numPublishers: 85 },
+  { date: new Date("30 Sep '23"), numPublishers: 87 },
+  { date: new Date("31 Oct '23"), numPublishers: 88 },
+  { date: new Date("30 Nov '23"), numPublishers: 89 },
+  { date: new Date("31 Dec '23"), numPublishers: 92 },
+  { date: new Date("31 Jan '24"), numPublishers: 96 },
+  { date: new Date("29 Feb '24"), numPublishers: 98 },
+  { date: new Date("31 Mar '24"), numPublishers: 100 },
+  { date: new Date("30 Apr '24"), numPublishers: 102 },
+  { date: new Date("31 May '24"), numPublishers: 106 },
+  { date: new Date("30 Jun '24"), numPublishers: 108 },
+  { date: new Date("31 Jul '24"), numPublishers: 111 },
+  { date: new Date("31 Aug '24"), numPublishers: 114 },
+  { date: new Date("30 Sep '24"), numPublishers: 117 },
+  { date: new Date("31 Oct '24"), numPublishers: 120 },
+  { date: new Date("30 Nov '24"), numPublishers: 121 },
+  { date: new Date("31 Dec '24"), numPublishers: 122 },
+];
+
+export const activeFeeds = [
+  { date: new Date("30 Aug '21"), numFeeds: 30 },
+  { date: new Date("29 Sep '21"), numFeeds: 38 },
+  { date: new Date("30 Oct '21"), numFeeds: 41 },
+  { date: new Date("29 Nov '21"), numFeeds: 43 },
+  { date: new Date("30 Dec '21"), numFeeds: 50 },
+  { date: new Date("29 Apr '22"), numFeeds: 55 },
+  { date: new Date("30 May '22"), numFeeds: 61 },
+  { date: new Date("29 Jun '22"), numFeeds: 67 },
+  { date: new Date("30 Aug '22"), numFeeds: 68 },
+  { date: new Date("30 Oct '22"), numFeeds: 127 },
+  { date: new Date("29 Nov '22"), numFeeds: 140 },
+  { date: new Date("30 Dec '22"), numFeeds: 147 },
+  { date: new Date("30 Jan '23"), numFeeds: 162 },
+  { date: new Date("27 Feb '23"), numFeeds: 175 },
+  { date: new Date("30 Mar '23"), numFeeds: 188 },
+  { date: new Date("29 Apr '23"), numFeeds: 199 },
+  { date: new Date("30 May '23"), numFeeds: 219 },
+  { date: new Date("29 Jun '23"), numFeeds: 234 },
+  { date: new Date("30 Jul '23"), numFeeds: 269 },
+  { date: new Date("30 Aug '23"), numFeeds: 301 },
+  { date: new Date("29 Sep '23"), numFeeds: 315 },
+  { date: new Date("30 Oct '23"), numFeeds: 325 },
+  { date: new Date("29 Nov '23"), numFeeds: 336 },
+  { date: new Date("30 Dec '23"), numFeeds: 352 },
+  { date: new Date("30 Jan '24"), numFeeds: 412 },
+  { date: new Date("28 Feb '24"), numFeeds: 418 },
+  { date: new Date("30 Mar '24"), numFeeds: 449 },
+  { date: new Date("29 Apr '24"), numFeeds: 467 },
+  { date: new Date("30 May '24"), numFeeds: 484 },
+  { date: new Date("29 Jun '24"), numFeeds: 490 },
+  { date: new Date("30 Jul '24"), numFeeds: 496 },
+  { date: new Date("30 Aug '24"), numFeeds: 501 },
+  { date: new Date("29 Sep '24"), numFeeds: 510 },
+  { date: new Date("30 Oct '24"), numFeeds: 540 },
+  { date: new Date("29 Nov '24"), numFeeds: 592 },
+  { date: new Date("30 Dec '24"), numFeeds: 637 },
+];

+ 36 - 0
packages/component-library/src/CrossfadeTabPanels/index.tsx

@@ -0,0 +1,36 @@
+"use client";
+
+import { AnimatePresence, motion } from "motion/react";
+import { type ReactNode, use } from "react";
+import { TabListStateContext } from "react-aria-components";
+
+import { TabPanel as UnstyledTabPanel } from "../unstyled/Tabs/index.js";
+
+const AnimatedPanel = motion(UnstyledTabPanel);
+
+type Props = {
+  items: {
+    id: string;
+    children: ReactNode;
+  }[];
+};
+
+export const CrossfadeTabPanels = ({ items }: Props) => {
+  const state = use(TabListStateContext);
+
+  return (
+    <AnimatePresence mode="popLayout" initial={false}>
+      {state && (
+        <AnimatedPanel
+          shouldForceMount
+          key={state.selectedKey}
+          initial={{ opacity: 0 }}
+          animate={{ opacity: 1 }}
+          exit={{ opacity: 0 }}
+          transition={{ duration: 0.5, ease: "linear" }}
+          {...items.find((item) => item.id === state.selectedKey)}
+        />
+      )}
+    </AnimatePresence>
+  );
+};

Some files were not shown because too many files changed in this diff