Merge pull request #24 from langchain-ai/brace/stock-prices

feat: Implement stock price component and api
This commit is contained in:
Brace Sproul
2025-03-06 11:37:26 -08:00
committed by GitHub
11 changed files with 1306 additions and 953 deletions

View File

@@ -5,6 +5,86 @@ import type ComponentMap from "../../uis/index";
import { z } from "zod"; import { z } from "zod";
import { LangGraphRunnableConfig } from "@langchain/langgraph"; import { LangGraphRunnableConfig } from "@langchain/langgraph";
import { findToolCall } from "../../find-tool-call"; import { findToolCall } from "../../find-tool-call";
import { format, subDays } from "date-fns";
import { Price, Snapshot } from "../../types";
async function getPricesForTicker(ticker: string): Promise<{
oneDayPrices: Price[];
thirtyDayPrices: Price[];
}> {
if (!process.env.FINANCIAL_DATASETS_API_KEY) {
throw new Error("Financial datasets API key not set");
}
const options = {
method: "GET",
headers: { "X-API-KEY": process.env.FINANCIAL_DATASETS_API_KEY },
};
const url = "https://api.financialdatasets.ai/prices";
const oneMonthAgo = format(subDays(new Date(), 30), "yyyy-MM-dd");
const now = format(new Date(), "yyyy-MM-dd");
const queryParamsOneDay = new URLSearchParams({
ticker,
interval: "minute",
interval_multiplier: "5",
start_date: now,
end_date: now,
limit: "5000",
});
const queryParamsThirtyDays = new URLSearchParams({
ticker,
interval: "minute",
interval_multiplier: "30",
start_date: oneMonthAgo,
end_date: now,
limit: "5000",
});
const [resOneDay, resThirtyDays] = await Promise.all([
fetch(`${url}?${queryParamsOneDay.toString()}`, options),
fetch(`${url}?${queryParamsThirtyDays.toString()}`, options),
]);
if (!resOneDay.ok || !resThirtyDays.ok) {
throw new Error("Failed to fetch prices");
}
const { prices: pricesOneDay } = await resOneDay.json();
const { prices: pricesThirtyDays } = await resThirtyDays.json();
return {
oneDayPrices: pricesOneDay,
thirtyDayPrices: pricesThirtyDays,
};
}
async function getPriceSnapshotForTicker(ticker: string): Promise<Snapshot> {
if (!process.env.FINANCIAL_DATASETS_API_KEY) {
throw new Error("Financial datasets API key not set");
}
const options = {
method: "GET",
headers: { "X-API-KEY": process.env.FINANCIAL_DATASETS_API_KEY },
};
const url = "https://api.financialdatasets.ai/prices/snapshot";
const queryParams = new URLSearchParams({
ticker,
});
const response = await fetch(`${url}?${queryParams.toString()}`, options);
if (!response.ok) {
throw new Error("Failed to fetch price snapshot");
}
const { snapshot } = await response.json();
return snapshot;
}
const llm = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 }); const llm = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0 });
@@ -14,6 +94,10 @@ const getStockPriceSchema = z.object({
const getPortfolioSchema = z.object({ const getPortfolioSchema = z.object({
get_portfolio: z.boolean().describe("Should be true."), get_portfolio: z.boolean().describe("Should be true."),
}); });
const buyStockSchema = z.object({
ticker: z.string().describe("The ticker symbol of the company"),
quantity: z.number().describe("The quantity of the stock to buy"),
});
const STOCKBROKER_TOOLS = [ const STOCKBROKER_TOOLS = [
{ {
@@ -27,6 +111,11 @@ const STOCKBROKER_TOOLS = [
"A tool to get the user's portfolio details. Only call this tool if the user requests their portfolio details.", "A tool to get the user's portfolio details. Only call this tool if the user requests their portfolio details.",
schema: getPortfolioSchema, schema: getPortfolioSchema,
}, },
{
name: "buy-stock",
description: "A tool to buy a stock",
schema: buyStockSchema,
},
]; ];
export async function callTools( export async function callTools(
@@ -48,20 +137,33 @@ export async function callTools(
findToolCall("stock-price")<typeof getStockPriceSchema>, findToolCall("stock-price")<typeof getStockPriceSchema>,
); );
const portfolioToolCall = message.tool_calls?.find( const portfolioToolCall = message.tool_calls?.find(
findToolCall("portfolio")<typeof getStockPriceSchema>, findToolCall("portfolio")<typeof getPortfolioSchema>,
);
const buyStockToolCall = message.tool_calls?.find(
findToolCall("buy-stock")<typeof buyStockSchema>,
); );
if (stockbrokerToolCall) { if (stockbrokerToolCall) {
const instruction = `The stock price of ${ const prices = await getPricesForTicker(stockbrokerToolCall.args.ticker);
stockbrokerToolCall.args.ticker ui.write("stock-price", {
} is ${Math.random() * 100}`; ticker: stockbrokerToolCall.args.ticker,
...prices,
ui.write("stock-price", { instruction, logo: "hey" }); });
} }
if (portfolioToolCall) { if (portfolioToolCall) {
ui.write("portfolio", {}); ui.write("portfolio", {});
} }
if (buyStockToolCall) {
const snapshot = await getPriceSnapshotForTicker(
buyStockToolCall.args.ticker,
);
ui.write("buy-stock", {
toolCallId:
message.tool_calls?.find((tc) => tc.name === "buy-stock")?.id ?? "",
snapshot,
quantity: buyStockToolCall.args.quantity,
});
}
return { return {
messages: [message], messages: [message],

View File

@@ -25,3 +25,22 @@ export type Accommodation = {
city: string; city: string;
image: string; image: string;
}; };
export type Price = {
ticker: string;
open: number;
close: number;
high: number;
low: number;
volume: number;
time: string;
};
export type Snapshot = {
price: number;
ticker: string;
day_change: number;
day_change_percent: number;
market_cap: number;
time: string;
};

View File

@@ -4,6 +4,7 @@ import AccommodationsList from "./trip-planner/accommodations-list";
import BookAccommodation from "./trip-planner/book-accommodation"; import BookAccommodation from "./trip-planner/book-accommodation";
import RestaurantsList from "./trip-planner/restaurants-list"; import RestaurantsList from "./trip-planner/restaurants-list";
import BookRestaurant from "./trip-planner/book-restaurant"; import BookRestaurant from "./trip-planner/book-restaurant";
import BuyStock from "./stockbroker/buy-stock";
const ComponentMap = { const ComponentMap = {
"stock-price": StockPrice, "stock-price": StockPrice,
@@ -12,5 +13,6 @@ const ComponentMap = {
"book-accommodation": BookAccommodation, "book-accommodation": BookAccommodation,
"restaurants-list": RestaurantsList, "restaurants-list": RestaurantsList,
"book-restaurant": BookRestaurant, "book-restaurant": BookRestaurant,
"buy-stock": BuyStock,
} as const; } as const;
export default ComponentMap; export default ComponentMap;

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,139 @@
import "./index.css";
import { v4 as uuidv4 } from "uuid";
import { Snapshot } from "../../../types";
import { Button } from "@/components/ui/button";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { UIMessage, useStreamContext } from "@langchain/langgraph-sdk/react-ui";
import { Message } from "@langchain/langgraph-sdk";
import { getToolResponse } from "agent/uis/utils/get-tool-response";
import { DO_NOT_RENDER_ID_PREFIX } from "@/lib/ensure-tool-responses";
function Purchased({
ticker,
quantity,
price,
}: {
ticker: string;
quantity: number;
price: number;
}) {
return (
<div className="w-full md:w-lg rounded-xl shadow-md overflow-hidden border border-gray-200 flex flex-col gap-4 p-3">
<h1 className="text-xl font-medium mb-2">Purchase Executed - {ticker}</h1>
<div className="grid grid-cols-2 gap-4 text-sm mb-4">
<div className="flex flex-col gap-2">
<p>Number of Shares</p>
<p>Market Price</p>
<p>Total Cost</p>
</div>
<div className="flex flex-col gap-2 items-end justify-end">
<p>{quantity}</p>
<p>${price}</p>
<p>${(quantity * price).toFixed(2)}</p>
</div>
</div>
</div>
);
}
export default function BuyStock(props: {
toolCallId: string;
snapshot: Snapshot;
quantity: number;
}) {
const { snapshot, toolCallId } = props;
const [quantity, setQuantity] = useState(props.quantity);
const [finalPurchase, setFinalPurchase] = useState<{
ticker: string;
quantity: number;
price: number;
}>();
const thread = useStreamContext<
{ messages: Message[]; ui: UIMessage[] },
{ MetaType: { ui: UIMessage | undefined } }
>();
useEffect(() => {
if (typeof window === "undefined" || finalPurchase) return;
const toolResponse = getToolResponse(toolCallId, thread);
if (toolResponse) {
try {
const parsedContent: {
purchaseDetails: {
ticker: string;
quantity: number;
price: number;
};
} = JSON.parse(toolResponse.content as string);
setFinalPurchase(parsedContent.purchaseDetails);
} catch {
console.error("Failed to parse tool response content.");
}
}
}, []);
function handleBuyStock() {
const orderDetails = {
message: "Successfully purchased stock",
purchaseDetails: {
ticker: snapshot.ticker,
quantity: quantity,
price: snapshot.price,
},
};
thread.submit({
messages: [
{
type: "tool",
tool_call_id: toolCallId,
id: `${DO_NOT_RENDER_ID_PREFIX}${uuidv4()}`,
name: "buy-stock",
content: JSON.stringify(orderDetails),
},
{
type: "human",
content: `Purchased ${quantity} shares of ${snapshot.ticker} at ${snapshot.price} per share`,
},
],
});
setFinalPurchase(orderDetails.purchaseDetails);
}
if (finalPurchase) {
return <Purchased {...finalPurchase} />;
}
return (
<div className="w-full md:w-lg rounded-xl shadow-md overflow-hidden border border-gray-200 flex flex-col gap-4 p-3">
<h1 className="text-xl font-medium mb-2">Buy {snapshot.ticker}</h1>
<div className="grid grid-cols-2 gap-4 text-sm mb-4">
<div className="flex flex-col gap-2">
<p>Number of Shares</p>
<p>Market Price</p>
<p>Total Cost</p>
</div>
<div className="flex flex-col gap-2 items-end justify-end">
<Input
type="number"
className="max-w-[100px] border-0 border-b focus:border-b-2 rounded-none shadow-none focus:ring-0"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
min={1}
/>
<p>${snapshot.price}</p>
<p>${(quantity * snapshot.price).toFixed(2)}</p>
</div>
</div>
<Button
className="w-full bg-green-600 hover:bg-green-700 transition-colors ease-in-out duration-200 cursor-pointer text-white"
onClick={handleBuyStock}
>
Buy
</Button>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -46,6 +46,7 @@
"react-markdown": "^10.0.1", "react-markdown": "^10.0.1",
"react-router-dom": "^6.17.0", "react-router-dom": "^6.17.0",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"recharts": "^2.15.1",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
@@ -70,6 +71,7 @@
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"dotenv": "^16.4.7",
"eslint": "^9.19.0", "eslint": "^9.19.0",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.18", "eslint-plugin-react-refresh": "^0.4.18",
@@ -80,5 +82,8 @@
"typescript-eslint": "^8.22.0", "typescript-eslint": "^8.22.0",
"vite": "^6.1.0" "vite": "^6.1.0"
}, },
"overrides": {
"react-is": "^19.0.0-rc-69d4b800-20241021"
},
"packageManager": "pnpm@10.5.1+sha512.c424c076bd25c1a5b188c37bb1ca56cc1e136fbf530d98bcb3289982a08fd25527b8c9c4ec113be5e3393c39af04521dd647bcf1d0801eaf8ac6a7b14da313af" "packageManager": "pnpm@10.5.1+sha512.c424c076bd25c1a5b188c37bb1ca56cc1e136fbf530d98bcb3289982a08fd25527b8c9c4ec113be5e3393c39af04521dd647bcf1d0801eaf8ac6a7b14da313af"
} }

391
pnpm-lock.yaml generated
View File

@@ -114,6 +114,9 @@ importers:
react-syntax-highlighter: react-syntax-highlighter:
specifier: ^15.5.0 specifier: ^15.5.0
version: 15.6.1(react@19.0.0) version: 15.6.1(react@19.0.0)
recharts:
specifier: ^2.15.1
version: 2.15.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
rehype-katex: rehype-katex:
specifier: ^7.0.1 specifier: ^7.0.1
version: 7.0.1 version: 7.0.1
@@ -166,6 +169,9 @@ importers:
autoprefixer: autoprefixer:
specifier: ^10.4.20 specifier: ^10.4.20
version: 10.4.20(postcss@8.5.3) version: 10.4.20(postcss@8.5.3)
dotenv:
specifier: ^16.4.7
version: 16.4.7
eslint: eslint:
specifier: ^9.19.0 specifier: ^9.19.0
version: 9.21.0(jiti@2.4.2) version: 9.21.0(jiti@2.4.2)
@@ -1850,6 +1856,60 @@ packages:
integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==, integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==,
} }
"@types/d3-array@3.2.1":
resolution:
{
integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==,
}
"@types/d3-color@3.1.3":
resolution:
{
integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==,
}
"@types/d3-ease@3.0.2":
resolution:
{
integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==,
}
"@types/d3-interpolate@3.0.4":
resolution:
{
integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==,
}
"@types/d3-path@3.1.1":
resolution:
{
integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==,
}
"@types/d3-scale@4.0.9":
resolution:
{
integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==,
}
"@types/d3-shape@3.1.7":
resolution:
{
integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==,
}
"@types/d3-time@3.0.4":
resolution:
{
integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==,
}
"@types/d3-timer@3.0.2":
resolution:
{
integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==,
}
"@types/debug@4.1.12": "@types/debug@4.1.12":
resolution: resolution:
{ {
@@ -2499,6 +2559,83 @@ packages:
integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==, integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==,
} }
d3-array@3.2.4:
resolution:
{
integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==,
}
engines: { node: ">=12" }
d3-color@3.1.0:
resolution:
{
integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==,
}
engines: { node: ">=12" }
d3-ease@3.0.1:
resolution:
{
integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==,
}
engines: { node: ">=12" }
d3-format@3.1.0:
resolution:
{
integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==,
}
engines: { node: ">=12" }
d3-interpolate@3.0.1:
resolution:
{
integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==,
}
engines: { node: ">=12" }
d3-path@3.1.0:
resolution:
{
integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==,
}
engines: { node: ">=12" }
d3-scale@4.0.2:
resolution:
{
integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==,
}
engines: { node: ">=12" }
d3-shape@3.2.0:
resolution:
{
integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==,
}
engines: { node: ">=12" }
d3-time-format@4.1.0:
resolution:
{
integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==,
}
engines: { node: ">=12" }
d3-time@3.1.0:
resolution:
{
integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==,
}
engines: { node: ">=12" }
d3-timer@3.0.1:
resolution:
{
integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==,
}
engines: { node: ">=12" }
date-fns@4.1.0: date-fns@4.1.0:
resolution: resolution:
{ {
@@ -2524,6 +2661,12 @@ packages:
} }
engines: { node: ">=0.10.0" } engines: { node: ">=0.10.0" }
decimal.js-light@2.5.1:
resolution:
{
integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==,
}
decode-named-character-reference@1.0.2: decode-named-character-reference@1.0.2:
resolution: resolution:
{ {
@@ -2602,6 +2745,12 @@ packages:
integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==, integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==,
} }
dom-helpers@5.2.1:
resolution:
{
integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==,
}
dotenv@16.4.7: dotenv@16.4.7:
resolution: resolution:
{ {
@@ -2898,6 +3047,13 @@ packages:
integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==,
} }
fast-equals@5.2.2:
resolution:
{
integrity: sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==,
}
engines: { node: ">=6.0.0" }
fast-glob@3.3.3: fast-glob@3.3.3:
resolution: resolution:
{ {
@@ -3354,6 +3510,13 @@ packages:
integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==, integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==,
} }
internmap@2.0.3:
resolution:
{
integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==,
}
engines: { node: ">=12" }
is-alphabetical@1.0.4: is-alphabetical@1.0.4:
resolution: resolution:
{ {
@@ -3743,6 +3906,12 @@ packages:
integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==, integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==,
} }
lodash@4.17.21:
resolution:
{
integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==,
}
logform@2.7.0: logform@2.7.0:
resolution: resolution:
{ {
@@ -3756,6 +3925,13 @@ packages:
integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==, integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==,
} }
loose-envify@1.4.0:
resolution:
{
integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==,
}
hasBin: true
lowlight@1.20.0: lowlight@1.20.0:
resolution: resolution:
{ {
@@ -4215,6 +4391,13 @@ packages:
} }
engines: { node: ">=18" } engines: { node: ">=18" }
object-assign@4.1.1:
resolution:
{
integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==,
}
engines: { node: ">=0.10.0" }
once@1.4.0: once@1.4.0:
resolution: resolution:
{ {
@@ -4491,6 +4674,12 @@ packages:
} }
engines: { node: ">=6" } engines: { node: ">=6" }
prop-types@15.8.1:
resolution:
{
integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==,
}
property-information@5.6.0: property-information@5.6.0:
resolution: resolution:
{ {
@@ -4530,6 +4719,18 @@ packages:
peerDependencies: peerDependencies:
react: ^19.0.0 react: ^19.0.0
react-is@16.13.1:
resolution:
{
integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==,
}
react-is@18.3.1:
resolution:
{
integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==,
}
react-markdown@10.0.1: react-markdown@10.0.1:
resolution: resolution:
{ {
@@ -4600,6 +4801,15 @@ packages:
peerDependencies: peerDependencies:
react: ">=16.8" react: ">=16.8"
react-smooth@4.0.4:
resolution:
{
integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==,
}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-style-singleton@2.2.3: react-style-singleton@2.2.3:
resolution: resolution:
{ {
@@ -4630,6 +4840,15 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-transition-group@4.4.5:
resolution:
{
integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==,
}
peerDependencies:
react: ">=16.6.0"
react-dom: ">=16.6.0"
react@19.0.0: react@19.0.0:
resolution: resolution:
{ {
@@ -4651,6 +4870,22 @@ packages:
} }
engines: { node: ">= 14.18.0" } engines: { node: ">= 14.18.0" }
recharts-scale@0.4.5:
resolution:
{
integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==,
}
recharts@2.15.1:
resolution:
{
integrity: sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==,
}
engines: { node: ">=14" }
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
refractor@3.6.0: refractor@3.6.0:
resolution: resolution:
{ {
@@ -5003,6 +5238,12 @@ packages:
integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==, integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==,
} }
tiny-invariant@1.3.3:
resolution:
{
integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==,
}
to-regex-range@5.0.1: to-regex-range@5.0.1:
resolution: resolution:
{ {
@@ -5303,6 +5544,12 @@ packages:
integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==, integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==,
} }
victory-vendor@36.9.2:
resolution:
{
integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==,
}
vite@6.2.0: vite@6.2.0:
resolution: resolution:
{ {
@@ -6512,6 +6759,30 @@ snapshots:
dependencies: dependencies:
"@babel/types": 7.26.9 "@babel/types": 7.26.9
"@types/d3-array@3.2.1": {}
"@types/d3-color@3.1.3": {}
"@types/d3-ease@3.0.2": {}
"@types/d3-interpolate@3.0.4":
dependencies:
"@types/d3-color": 3.1.3
"@types/d3-path@3.1.1": {}
"@types/d3-scale@4.0.9":
dependencies:
"@types/d3-time": 3.0.4
"@types/d3-shape@3.1.7":
dependencies:
"@types/d3-path": 3.1.1
"@types/d3-time@3.0.4": {}
"@types/d3-timer@3.0.2": {}
"@types/debug@4.1.12": "@types/debug@4.1.12":
dependencies: dependencies:
"@types/ms": 2.1.0 "@types/ms": 2.1.0
@@ -6880,6 +7151,44 @@ snapshots:
csstype@3.1.3: {} csstype@3.1.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.0: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.0
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
date-fns@4.1.0: {} date-fns@4.1.0: {}
debug@4.4.0: debug@4.4.0:
@@ -6888,6 +7197,8 @@ snapshots:
decamelize@1.2.0: {} decamelize@1.2.0: {}
decimal.js-light@2.5.1: {}
decode-named-character-reference@1.0.2: decode-named-character-reference@1.0.2:
dependencies: dependencies:
character-entities: 2.0.2 character-entities: 2.0.2
@@ -6917,6 +7228,11 @@ snapshots:
dependencies: dependencies:
dequal: 2.0.3 dequal: 2.0.3
dom-helpers@5.2.1:
dependencies:
"@babel/runtime": 7.26.9
csstype: 3.1.3
dotenv@16.4.7: {} dotenv@16.4.7: {}
dunder-proto@1.0.1: dunder-proto@1.0.1:
@@ -7154,6 +7470,8 @@ snapshots:
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-equals@5.2.2: {}
fast-glob@3.3.3: fast-glob@3.3.3:
dependencies: dependencies:
"@nodelib/fs.stat": 2.0.5 "@nodelib/fs.stat": 2.0.5
@@ -7444,6 +7762,8 @@ snapshots:
inline-style-parser@0.2.4: {} inline-style-parser@0.2.4: {}
internmap@2.0.3: {}
is-alphabetical@1.0.4: {} is-alphabetical@1.0.4: {}
is-alphabetical@2.0.1: {} is-alphabetical@2.0.1: {}
@@ -7623,6 +7943,8 @@ snapshots:
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
lodash@4.17.21: {}
logform@2.7.0: logform@2.7.0:
dependencies: dependencies:
"@colors/colors": 1.6.0 "@colors/colors": 1.6.0
@@ -7634,6 +7956,10 @@ snapshots:
longest-streak@3.1.0: {} longest-streak@3.1.0: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
lowlight@1.20.0: lowlight@1.20.0:
dependencies: dependencies:
fault: 1.0.4 fault: 1.0.4
@@ -8083,6 +8409,8 @@ snapshots:
path-key: 4.0.0 path-key: 4.0.0
unicorn-magic: 0.3.0 unicorn-magic: 0.3.0
object-assign@4.1.1: {}
once@1.4.0: once@1.4.0:
dependencies: dependencies:
wrappy: 1.0.2 wrappy: 1.0.2
@@ -8257,6 +8585,12 @@ snapshots:
prismjs@1.29.0: {} prismjs@1.29.0: {}
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
property-information@5.6.0: property-information@5.6.0:
dependencies: dependencies:
xtend: 4.0.2 xtend: 4.0.2
@@ -8277,6 +8611,10 @@ snapshots:
react: 19.0.0 react: 19.0.0
scheduler: 0.25.0 scheduler: 0.25.0
react-is@16.13.1: {}
react-is@18.3.1: {}
react-markdown@10.0.1(@types/react@19.0.10)(react@19.0.0): react-markdown@10.0.1(@types/react@19.0.10)(react@19.0.0):
dependencies: dependencies:
"@types/hast": 3.0.4 "@types/hast": 3.0.4
@@ -8346,6 +8684,14 @@ snapshots:
"@remix-run/router": 1.23.0 "@remix-run/router": 1.23.0
react: 19.0.0 react: 19.0.0
react-smooth@4.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
fast-equals: 5.2.2
prop-types: 15.8.1
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react-transition-group: 4.4.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react-style-singleton@2.2.3(@types/react@19.0.10)(react@19.0.0): react-style-singleton@2.2.3(@types/react@19.0.10)(react@19.0.0):
dependencies: dependencies:
get-nonce: 1.0.1 get-nonce: 1.0.1
@@ -8373,6 +8719,15 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- "@types/react" - "@types/react"
react-transition-group@4.4.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
"@babel/runtime": 7.26.9
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react@19.0.0: {} react@19.0.0: {}
readable-stream@3.6.2: readable-stream@3.6.2:
@@ -8383,6 +8738,23 @@ snapshots:
readdirp@4.1.2: {} readdirp@4.1.2: {}
recharts-scale@0.4.5:
dependencies:
decimal.js-light: 2.5.1
recharts@2.15.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
clsx: 2.1.1
eventemitter3: 4.0.7
lodash: 4.17.21
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react-is: 18.3.1
react-smooth: 4.0.4(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
recharts-scale: 0.4.5
tiny-invariant: 1.3.3
victory-vendor: 36.9.2
refractor@3.6.0: refractor@3.6.0:
dependencies: dependencies:
hastscript: 6.0.0 hastscript: 6.0.0
@@ -8607,6 +8979,8 @@ snapshots:
text-hex@1.0.0: {} text-hex@1.0.0: {}
tiny-invariant@1.3.3: {}
to-regex-range@5.0.1: to-regex-range@5.0.1:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0
@@ -8778,6 +9152,23 @@ snapshots:
"@types/unist": 3.0.3 "@types/unist": 3.0.3
vfile-message: 4.0.2 vfile-message: 4.0.2
victory-vendor@36.9.2:
dependencies:
"@types/d3-array": 3.2.1
"@types/d3-ease": 3.0.2
"@types/d3-interpolate": 3.0.4
"@types/d3-scale": 4.0.9
"@types/d3-shape": 3.1.7
"@types/d3-time": 3.0.4
"@types/d3-timer": 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
vite@6.2.0(@types/node@22.13.5)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.3)(yaml@2.7.0): vite@6.2.0(@types/node@22.13.5)(jiti@2.4.2)(lightningcss@1.29.1)(tsx@4.19.3)(yaml@2.7.0):
dependencies: dependencies:
esbuild: 0.25.0 esbuild: 0.25.0

View File

@@ -0,0 +1,75 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn("flex flex-col gap-1.5 px-6", className)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

353
src/components/ui/chart.tsx Normal file
View File

@@ -0,0 +1,353 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return (
<div className={cn("font-medium bg-white", labelClassName)}>{value}</div>
);
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -121,6 +121,21 @@
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
:root {
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
} }
@layer utilities { @layer utilities {