diff --git a/e2e/tests/shared/mocked/gas-tendency-chart.spec.ts b/e2e/tests/shared/mocked/gas-tendency-chart.spec.ts new file mode 100644 index 00000000..3dbfeee9 --- /dev/null +++ b/e2e/tests/shared/mocked/gas-tendency-chart.spec.ts @@ -0,0 +1,28 @@ +import { test } from "../../../fixtures/test"; + +/** + * Hermetic regression test for the Gas Tendency chart on the Network and + * Blocks pages. The chart fetches `eth_feeHistory(0x32, "latest")` via + * `useFeeHistory` and renders an SVG sparkline gated on + * `dataService.isEVM()`. + * + * Placeholder in phase 1 — the hermetic version requires: + * 1. Mocking `eth_blockNumber`, `eth_getBlockByNumber`, `eth_gasPrice`, + * `eth_feeHistory`, and `eth_getTransactionByHash` for the full page + * load, and asserting `eth_feeHistory` is called with `0x32` (50). + * 2. Asserting `[data-testid="gas-tendency-chart"]` is visible after the + * mocked response resolves, on both `/` and `//blocks`. + * 3. Asserting the chart is NOT rendered on Bitcoin / Solana routes. + * 4. Hovering an SVG point and asserting the tooltip surfaces the + * expected block number, base fee (gwei), and gas-used %. + * + * Phase 4 wires these together using the `rpcMock` helpers shared with + * the other hermetic specs in this folder. + */ + +test.describe("Gas Tendency chart — TODO phase 4", () => { + test.skip("renders on the Network page with mocked eth_feeHistory", async () => {}); + test.skip("renders on the Blocks page with mocked eth_feeHistory", async () => {}); + test.skip("is absent on Bitcoin and Solana routes", async () => {}); + test.skip("hover tooltip shows block number, base fee, and gas used", async () => {}); +}); diff --git a/src/components/pages/evm/blocks/index.tsx b/src/components/pages/evm/blocks/index.tsx index f2bb35a0..e675085c 100644 --- a/src/components/pages/evm/blocks/index.tsx +++ b/src/components/pages/evm/blocks/index.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { RPCIndicator } from "../../../common/RPCIndicator"; import Breadcrumb from "../../../common/Breadcrumb"; +import GasTendencyChartSection from "../shared/GasTendencyChartSection"; import { getNetworkById } from "../../../../config/networks"; import { useDataService } from "../../../../hooks/useDataService"; import { useProviderSelection } from "../../../../hooks/useProviderSelection"; @@ -281,6 +282,8 @@ export default function Blocks() { )} + +
diff --git a/src/components/pages/evm/network/index.tsx b/src/components/pages/evm/network/index.tsx index d1e885ab..edcfcc24 100644 --- a/src/components/pages/evm/network/index.tsx +++ b/src/components/pages/evm/network/index.tsx @@ -6,6 +6,7 @@ import { resolveNetwork, getChainIdFromNetwork } from "../../../../utils/network import { getAllNetworks } from "../../../../config/networks"; import SearchBox from "../../../common/SearchBox"; import DashboardStats from "./DashboardStats"; +import GasTendencyChartSection from "../shared/GasTendencyChartSection"; import LatestBlocksTable from "./LatestBlocksTable"; import LatestTransactionsTable from "./LatestTransactionsTable"; import ProfileDisplay from "./NetworkProfileDisplay"; @@ -68,6 +69,8 @@ export default function Network() { networkId={chainId} /> + {network && } +
{ + const whole = wei / WEI_PER_GWEI; + const remainder = wei % WEI_PER_GWEI; + return Number(whole) + Number(remainder) / Number(WEI_PER_GWEI); +}; + +const formatGwei = (gwei: number): string => { + if (gwei >= 100) return gwei.toFixed(0); + if (gwei >= 10) return gwei.toFixed(1); + if (gwei >= 1) return gwei.toFixed(2); + return gwei.toFixed(3); +}; + +const GasTendencyChart: React.FC = ({ feeHistory, isLoading }) => { + const { t } = useTranslation("network"); + const [hoverIdx, setHoverIdx] = useState(null); + + const chartData = useMemo(() => { + if (!feeHistory || feeHistory.gasUsedRatio.length === 0) return null; + const ratios = feeHistory.gasUsedRatio; + const baseFees = feeHistory.baseFeePerGas.slice(0, ratios.length); // align lengths + const gweis = baseFees.map(weiToGwei); + const minGwei = Math.min(...gweis); + const maxGwei = Math.max(...gweis); + const range = Math.max(maxGwei - minGwei, 1e-9); + return { + ratios, + gweis, + minGwei, + maxGwei, + range, + oldestBlock: feeHistory.oldestBlock, + newestBlock: feeHistory.oldestBlock + ratios.length - 1, + }; + }, [feeHistory]); + + if (isLoading && !chartData) { + return ( +
+
+
+ ); + } + + if (!chartData) { + return null; + } + + const { ratios, gweis, minGwei, maxGwei, range, oldestBlock, newestBlock } = chartData; + const n = ratios.length; + const barWidth = VIEW_WIDTH / n; + + const pointX = (i: number) => (i + 0.5) * barWidth; + const pointY = (gwei: number) => { + // 8% top/bottom padding so the line never hits the edges + const t = (gwei - minGwei) / range; + return VIEW_HEIGHT * (0.9 - 0.8 * t); + }; + + const linePath = gweis + .map((g, i) => `${i === 0 ? "M" : "L"} ${pointX(i).toFixed(2)} ${pointY(g).toFixed(2)}`) + .join(" "); + + // Area under the line for soft fill + const areaPath = `${linePath} L ${pointX(n - 1).toFixed(2)} ${VIEW_HEIGHT} L ${pointX(0).toFixed(2)} ${VIEW_HEIGHT} Z`; + + const latestGwei = gweis[n - 1]; + + return ( +
+
+ + {formatGwei(latestGwei ?? 0)} gwei + + + {formatGwei(minGwei)} – {formatGwei(maxGwei)} gwei + +
+ +
+ + { + const rect = e.currentTarget.getBoundingClientRect(); + const xRatio = (e.clientX - rect.left) / rect.width; + const idx = Math.min(n - 1, Math.max(0, Math.floor(xRatio * n))); + setHoverIdx(idx); + }} + onMouseLeave={() => setHoverIdx(null)} + > + {t("gasTendency.title")} + {/* Gas-used % bars behind the line */} + {ratios.map((r, i) => { + const h = Math.max(r * VIEW_HEIGHT, 1); + return ( + + ); + })} + + {/* 50% gas-used reference line */} + + + {/* Base fee area + line */} + + + + {/* Hover guide */} + {hoverIdx !== null && gweis[hoverIdx] !== undefined && ( + <> + + + + )} + + + {hoverIdx !== null && gweis[hoverIdx] !== undefined && ( +
+
+ {t("gasTendency.tooltipBlock", { number: oldestBlock + hoverIdx })} +
+
+ {t("gasTendency.tooltipBaseFee", { value: formatGwei(gweis[hoverIdx] as number) })} +
+
+ {t("gasTendency.tooltipGasUsed", { + value: ((ratios[hoverIdx] ?? 0) * 100).toFixed(1), + })} +
+
+ )} +
+ +
+ {t("gasTendency.blockShort", { number: oldestBlock })} + + {t("gasTendency.baseFee")} + {t("gasTendency.gasUsed")} + + {t("gasTendency.blockShort", { number: newestBlock })} +
+
+ ); +}; + +export default GasTendencyChart; diff --git a/src/components/pages/evm/shared/GasTendencyChartSection.tsx b/src/components/pages/evm/shared/GasTendencyChartSection.tsx new file mode 100644 index 00000000..822cc620 --- /dev/null +++ b/src/components/pages/evm/shared/GasTendencyChartSection.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from "react-i18next"; +import type { NetworkConfig } from "../../../../types"; +import { useFeeHistory } from "../../../../hooks/useFeeHistory"; +import GasTendencyChart from "./GasTendencyChart"; + +interface Props { + network: NetworkConfig | number; + namespace?: "network" | "block"; + blockCount?: number; +} + +const DEFAULT_BLOCK_COUNT = 50; + +const GasTendencyChartSection: React.FC = ({ + network, + namespace = "network", + blockCount = DEFAULT_BLOCK_COUNT, +}) => { + const { t } = useTranslation(namespace); + const { feeHistory, isLoading, error } = useFeeHistory(network, blockCount); + + // Hide the section entirely on error or when there's no usable data after loading. + if (error || (!isLoading && (!feeHistory || feeHistory.gasUsedRatio.length === 0))) { + return null; + } + + return ( +
+
+

{t("gasTendency.title")}

+
+ +
+ ); +}; + +export default GasTendencyChartSection; diff --git a/src/hooks/useFeeHistory.test.ts b/src/hooks/useFeeHistory.test.ts new file mode 100644 index 00000000..c40be2ba --- /dev/null +++ b/src/hooks/useFeeHistory.test.ts @@ -0,0 +1,103 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { FeeHistory } from "../types"; + +const mockGetFeeHistory = vi.fn(); +const mockUseDataService = vi.fn(); + +vi.mock("./useDataService", () => ({ + useDataService: (...args: unknown[]) => mockUseDataService(...args), +})); + +const sampleFeeHistory: FeeHistory = { + oldestBlock: 1000, + baseFeePerGas: [1_000_000_000n, 1_100_000_000n, 1_200_000_000n], + gasUsedRatio: [0.5, 0.6, 0.7], +}; + +const buildDataService = (isEvm: boolean) => ({ + isEVM: () => isEvm, + getEVMAdapter: () => ({ getFeeHistory: mockGetFeeHistory }), +}); + +describe("useFeeHistory", () => { + beforeEach(() => { + mockGetFeeHistory.mockReset(); + mockUseDataService.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns null when network is not EVM", async () => { + mockUseDataService.mockReturnValue(buildDataService(false)); + + const { useFeeHistory } = await import("./useFeeHistory"); + const { result } = renderHook(() => useFeeHistory(1)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + expect(result.current.feeHistory).toBeNull(); + expect(mockGetFeeHistory).not.toHaveBeenCalled(); + }); + + it("returns null when dataService is null (loading state)", async () => { + mockUseDataService.mockReturnValue(null); + + const { useFeeHistory } = await import("./useFeeHistory"); + const { result } = renderHook(() => useFeeHistory(1)); + + expect(result.current.feeHistory).toBeNull(); + expect(mockGetFeeHistory).not.toHaveBeenCalled(); + }); + + it("fetches fee history for EVM networks", async () => { + mockUseDataService.mockReturnValue(buildDataService(true)); + mockGetFeeHistory.mockResolvedValue({ data: sampleFeeHistory }); + + const { useFeeHistory } = await import("./useFeeHistory"); + const { result } = renderHook(() => useFeeHistory(1, 50)); + + await waitFor(() => { + expect(result.current.feeHistory).toEqual(sampleFeeHistory); + }); + expect(mockGetFeeHistory).toHaveBeenCalledWith(50); + }); + + it("registers an interval for refreshes and clears it on unmount", async () => { + const setIntervalSpy = vi.spyOn(global, "setInterval"); + const clearIntervalSpy = vi.spyOn(global, "clearInterval"); + + mockUseDataService.mockReturnValue(buildDataService(true)); + mockGetFeeHistory.mockResolvedValue({ data: sampleFeeHistory }); + + const { useFeeHistory } = await import("./useFeeHistory"); + const { unmount } = renderHook(() => useFeeHistory(1, 50)); + + await waitFor(() => { + expect(mockGetFeeHistory).toHaveBeenCalledTimes(1); + }); + + const intervalCall = setIntervalSpy.mock.calls.find(([, ms]) => ms === 10_000); + expect(intervalCall).toBeDefined(); + + unmount(); + expect(clearIntervalSpy).toHaveBeenCalled(); + }); + + it("captures errors without throwing", async () => { + mockUseDataService.mockReturnValue(buildDataService(true)); + mockGetFeeHistory.mockRejectedValue(new Error("RPC down")); + + const { useFeeHistory } = await import("./useFeeHistory"); + const { result } = renderHook(() => useFeeHistory(1, 50)); + + await waitFor(() => { + expect(result.current.error).toBeInstanceOf(Error); + }); + expect(result.current.error?.message).toBe("RPC down"); + expect(result.current.feeHistory).toBeNull(); + }); +}); diff --git a/src/hooks/useFeeHistory.ts b/src/hooks/useFeeHistory.ts new file mode 100644 index 00000000..d4829a97 --- /dev/null +++ b/src/hooks/useFeeHistory.ts @@ -0,0 +1,72 @@ +/** + * Hook for fetching base fee + gas-used ratio history for the latest N blocks. + * Powers the gas tendency chart on Network and Blocks pages. + * Returns null for non-EVM networks. + */ + +import { useEffect, useRef, useState } from "react"; +import type { FeeHistory, NetworkConfig } from "../types"; +import { logger } from "../utils/logger"; +import { useDataService } from "./useDataService"; + +const REFRESH_INTERVAL = 10000; // 10 seconds — matches useNetworkDashboard cadence +const DEFAULT_BLOCK_COUNT = 50; + +export interface UseFeeHistoryResult { + feeHistory: FeeHistory | null; + isLoading: boolean; + error: Error | null; +} + +export function useFeeHistory( + network: NetworkConfig | number, + blockCount: number = DEFAULT_BLOCK_COUNT, +): UseFeeHistoryResult { + const dataService = useDataService(network); + const [feeHistory, setFeeHistory] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const isFetchingRef = useRef(false); + + useEffect(() => { + if (!dataService || !dataService.isEVM()) { + setFeeHistory(null); + setIsLoading(false); + return; + } + + let cancelled = false; + + const fetchHistory = async () => { + if (isFetchingRef.current) return; + isFetchingRef.current = true; + + try { + const adapter = dataService.getEVMAdapter(); + const result = await adapter.getFeeHistory(blockCount); + if (cancelled) return; + setFeeHistory(result.data); + setError(null); + } catch (err) { + if (cancelled) return; + const e = err instanceof Error ? err : new Error("Failed to fetch fee history"); + logger.warn("useFeeHistory: fetch failed", e); + setError(e); + } finally { + isFetchingRef.current = false; + if (!cancelled) setIsLoading(false); + } + }; + + setIsLoading(true); + fetchHistory(); + const intervalId = setInterval(fetchHistory, REFRESH_INTERVAL); + + return () => { + cancelled = true; + clearInterval(intervalId); + }; + }, [dataService, blockCount]); + + return { feeHistory, isLoading, error }; +} diff --git a/src/locales/en/block.json b/src/locales/en/block.json index bd5bcba6..1f8b52b7 100644 --- a/src/locales/en/block.json +++ b/src/locales/en/block.json @@ -89,5 +89,14 @@ "errors": { "failedToFetchBlock": "Failed to fetch block data", "failedToFetchBlocks": "Failed to fetch blocks" + }, + "gasTendency": { + "title": "Gas Tendency", + "baseFee": "Base fee (gwei)", + "gasUsed": "Gas used", + "blockShort": "#{{number}}", + "tooltipBlock": "Block #{{number}}", + "tooltipBaseFee": "Base fee: {{value}} gwei", + "tooltipGasUsed": "Gas used: {{value}}%" } } diff --git a/src/locales/en/network.json b/src/locales/en/network.json index 644143a6..ef5e0322 100644 --- a/src/locales/en/network.json +++ b/src/locales/en/network.json @@ -39,6 +39,15 @@ "aboutNetwork": "About {{name}}", "officialLinks": "Official Links", "collapseProfile": "Collapse profile", + "gasTendency": { + "title": "Gas Tendency", + "baseFee": "Base fee (gwei)", + "gasUsed": "Gas used", + "blockShort": "#{{number}}", + "tooltipBlock": "Block #{{number}}", + "tooltipBaseFee": "Base fee: {{value}} gwei", + "tooltipGasUsed": "Gas used: {{value}}%" + }, "gasTracker": { "title": "Gas Tracker", "loadingGasPrices": "Loading gas prices...", diff --git a/src/locales/es/block.json b/src/locales/es/block.json index 4a1fd4cf..e2b67c66 100644 --- a/src/locales/es/block.json +++ b/src/locales/es/block.json @@ -89,5 +89,14 @@ "errors": { "failedToFetchBlock": "No se pudieron obtener los datos del bloque", "failedToFetchBlocks": "No se pudieron obtener los bloques" + }, + "gasTendency": { + "title": "Tendencia del Gas", + "baseFee": "Tarifa base (gwei)", + "gasUsed": "Gas usado", + "blockShort": "#{{number}}", + "tooltipBlock": "Bloque #{{number}}", + "tooltipBaseFee": "Tarifa base: {{value}} gwei", + "tooltipGasUsed": "Gas usado: {{value}}%" } } diff --git a/src/locales/es/network.json b/src/locales/es/network.json index 5e038351..eb5ff781 100644 --- a/src/locales/es/network.json +++ b/src/locales/es/network.json @@ -39,6 +39,15 @@ "aboutNetwork": "Acerca de {{name}}", "officialLinks": "Links Oficiales", "collapseProfile": "Contraer perfil", + "gasTendency": { + "title": "Tendencia del Gas", + "baseFee": "Tarifa base (gwei)", + "gasUsed": "Gas usado", + "blockShort": "#{{number}}", + "tooltipBlock": "Bloque #{{number}}", + "tooltipBaseFee": "Tarifa base: {{value}} gwei", + "tooltipGasUsed": "Gas usado: {{value}}%" + }, "gasTracker": { "title": "Gas Tracker", "loadingGasPrices": "Cargando precios de gas...", diff --git a/src/locales/ja/block.json b/src/locales/ja/block.json index c2e3b256..70167131 100644 --- a/src/locales/ja/block.json +++ b/src/locales/ja/block.json @@ -72,5 +72,14 @@ "errors": { "failedToFetchBlock": "ブロックデータの取得に失敗しました", "failedToFetchBlocks": "ブロックの取得に失敗しました" + }, + "gasTendency": { + "title": "ガスの傾向", + "baseFee": "ベース料金 (gwei)", + "gasUsed": "ガス使用量", + "blockShort": "#{{number}}", + "tooltipBlock": "ブロック #{{number}}", + "tooltipBaseFee": "ベース料金: {{value}} gwei", + "tooltipGasUsed": "ガス使用量: {{value}}%" } } diff --git a/src/locales/ja/network.json b/src/locales/ja/network.json index ef8a6ec6..7b907c7c 100644 --- a/src/locales/ja/network.json +++ b/src/locales/ja/network.json @@ -39,6 +39,15 @@ "aboutNetwork": "{{name}}について", "officialLinks": "公式リンク", "collapseProfile": "プロフィールを折りたたむ", + "gasTendency": { + "title": "ガスの傾向", + "baseFee": "ベース料金 (gwei)", + "gasUsed": "ガス使用量", + "blockShort": "#{{number}}", + "tooltipBlock": "ブロック #{{number}}", + "tooltipBaseFee": "ベース料金: {{value}} gwei", + "tooltipGasUsed": "ガス使用量: {{value}}%" + }, "gasTracker": { "title": "ガストラッカー", "loadingGasPrices": "ガス価格を読み込み中...", diff --git a/src/locales/pt-BR/block.json b/src/locales/pt-BR/block.json index f0afbe4b..3ed08638 100644 --- a/src/locales/pt-BR/block.json +++ b/src/locales/pt-BR/block.json @@ -72,5 +72,14 @@ "errors": { "failedToFetchBlock": "Falha ao buscar dados do bloco", "failedToFetchBlocks": "Falha ao buscar blocos" + }, + "gasTendency": { + "title": "Tendência do Gas", + "baseFee": "Taxa base (gwei)", + "gasUsed": "Gas usado", + "blockShort": "#{{number}}", + "tooltipBlock": "Bloco #{{number}}", + "tooltipBaseFee": "Taxa base: {{value}} gwei", + "tooltipGasUsed": "Gas usado: {{value}}%" } } diff --git a/src/locales/pt-BR/network.json b/src/locales/pt-BR/network.json index 8550f03b..203df8fd 100644 --- a/src/locales/pt-BR/network.json +++ b/src/locales/pt-BR/network.json @@ -39,6 +39,15 @@ "aboutNetwork": "Sobre {{name}}", "officialLinks": "Links Oficiais", "collapseProfile": "Recolher perfil", + "gasTendency": { + "title": "Tendência do Gas", + "baseFee": "Taxa base (gwei)", + "gasUsed": "Gas usado", + "blockShort": "#{{number}}", + "tooltipBlock": "Bloco #{{number}}", + "tooltipBaseFee": "Taxa base: {{value}} gwei", + "tooltipGasUsed": "Gas usado: {{value}}%" + }, "gasTracker": { "title": "Rastreador de Gas", "loadingGasPrices": "Carregando preços de gas...", diff --git a/src/locales/zh/block.json b/src/locales/zh/block.json index c3a973b0..ea9e4f14 100644 --- a/src/locales/zh/block.json +++ b/src/locales/zh/block.json @@ -72,5 +72,14 @@ "errors": { "failedToFetchBlock": "获取区块数据失败", "failedToFetchBlocks": "获取区块列表失败" + }, + "gasTendency": { + "title": "Gas 趋势", + "baseFee": "基础费用 (gwei)", + "gasUsed": "Gas 使用量", + "blockShort": "#{{number}}", + "tooltipBlock": "区块 #{{number}}", + "tooltipBaseFee": "基础费用: {{value}} gwei", + "tooltipGasUsed": "Gas 使用量: {{value}}%" } } diff --git a/src/locales/zh/network.json b/src/locales/zh/network.json index af1e3b86..6d1d62c8 100644 --- a/src/locales/zh/network.json +++ b/src/locales/zh/network.json @@ -39,6 +39,15 @@ "aboutNetwork": "关于 {{name}}", "officialLinks": "官方链接", "collapseProfile": "折叠简介", + "gasTendency": { + "title": "Gas 趋势", + "baseFee": "基础费用 (gwei)", + "gasUsed": "Gas 使用量", + "blockShort": "#{{number}}", + "tooltipBlock": "区块 #{{number}}", + "tooltipBaseFee": "基础费用: {{value}} gwei", + "tooltipGasUsed": "Gas 使用量: {{value}}%" + }, "gasTracker": { "title": "Gas 追踪器", "loadingGasPrices": "加载 Gas 价格...", diff --git a/src/services/adapters/NetworkAdapter.ts b/src/services/adapters/NetworkAdapter.ts index 007a3743..c405f2ff 100644 --- a/src/services/adapters/NetworkAdapter.ts +++ b/src/services/adapters/NetworkAdapter.ts @@ -8,6 +8,7 @@ import type { DataWithMetadata, AddressTransactionsResult, GasPrices, + FeeHistory, } from "../../types"; import { logger } from "../../utils/logger"; @@ -343,6 +344,41 @@ export abstract class NetworkAdapter { }; } + /** + * Get base fee + gas-used ratio history for the latest N blocks via eth_feeHistory. + * Returns ready-to-plot values (bigint base fees in wei, decimal gas-used ratios). + * @param blockCount - Number of blocks to retrieve (e.g. 50) + * @param newestBlock - Newest block in the range (default "latest") + * @returns Fee history with base fees and gas-used ratios + */ + async getFeeHistory( + blockCount: number, + newestBlock: BlockNumberOrTag = "latest", + ): Promise> { + const client = this.getClient(); + const hexCount = `0x${blockCount.toString(16)}`; + const newest = typeof newestBlock === "number" ? `0x${newestBlock.toString(16)}` : newestBlock; + + const result = await client.feeHistory(hexCount, newest); + const raw = result.data; + + if (!raw || !raw.baseFeePerGas || !raw.gasUsedRatio) { + return { + data: { oldestBlock: 0, baseFeePerGas: [], gasUsedRatio: [] }, + metadata: result.metadata as DataWithMetadata["metadata"], + }; + } + + return { + data: { + oldestBlock: Number(BigInt(raw.oldestBlock)), + baseFeePerGas: raw.baseFeePerGas.map((v: string) => BigInt(v)), + gasUsedRatio: raw.gasUsedRatio, + }, + metadata: result.metadata as DataWithMetadata["metadata"], + }; + } + /** * Get latest N blocks * @param count - Number of blocks to retrieve diff --git a/src/styles/components.css b/src/styles/components.css index 8860a0f2..89a0c6b9 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -8848,3 +8848,204 @@ button.tx-section-header-toggle { .copy-button-copied { color: var(--color-primary, #3ecfa0); } + +/* ========== Gas Tendency Chart ========== */ +.gas-tendency-section { + margin-bottom: 16px; +} + +.gas-tendency-chart { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.gas-tendency-chart-meta { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.gas-tendency-chart-meta-value { + font-family: "Outfit", sans-serif; + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary); +} + +.gas-tendency-chart-meta-unit { + font-size: 0.85rem; + font-weight: 400; + color: var(--text-secondary); +} + +.gas-tendency-chart-meta-range { + font-size: 0.8rem; + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; +} + +.gas-tendency-chart-plot { + position: relative; + width: 100%; + height: 140px; +} + +.gas-tendency-chart-svg { + width: 100%; + height: 100%; + display: block; + overflow: visible; +} + +.gas-tendency-chart-bar { + fill: var(--color-primary-alpha-15, rgba(99, 102, 241, 0.15)); +} + +.gas-tendency-chart-area { + fill: var(--color-primary-alpha-15, rgba(99, 102, 241, 0.15)); + stroke: none; +} + +.gas-tendency-chart-line { + fill: none; + stroke: var(--color-primary); + stroke-width: 2; + stroke-linejoin: round; + stroke-linecap: round; + vector-effect: non-scaling-stroke; +} + +.gas-tendency-chart-guide { + stroke: var(--text-tertiary); + stroke-width: 1; + stroke-dasharray: 3 3; + vector-effect: non-scaling-stroke; + pointer-events: none; +} + +.gas-tendency-chart-reference-line { + stroke: var(--text-tertiary); + stroke-width: 1; + stroke-dasharray: 2 4; + stroke-opacity: 0.6; + vector-effect: non-scaling-stroke; + pointer-events: none; +} + +.gas-tendency-chart-reference-label { + position: absolute; + top: 50%; + right: 4px; + transform: translateY(-50%); + font-size: 0.7rem; + color: var(--text-tertiary); + background: var(--bg-secondary); + padding: 0 4px; + border-radius: 3px; + font-variant-numeric: tabular-nums; + pointer-events: none; + z-index: 1; +} + +.gas-tendency-chart-dot { + fill: var(--color-primary); + stroke: var(--bg-secondary); + stroke-width: 2; + vector-effect: non-scaling-stroke; + pointer-events: none; +} + +.gas-tendency-chart-tooltip { + position: absolute; + bottom: calc(100% + 6px); + transform: translateX(-50%); + background: var(--bg-primary); + border: 1px solid var(--overlay-light-6); + border-radius: 8px; + padding: 8px 10px; + min-width: 140px; + font-size: 0.75rem; + color: var(--text-secondary); + pointer-events: none; + z-index: 2; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); +} + +.gas-tendency-chart-tooltip-block { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + +.gas-tendency-chart-tooltip-row { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.gas-tendency-chart-axis { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + font-size: 0.75rem; + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; +} + +.gas-tendency-chart-axis-legend { + display: inline-flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.gas-tendency-chart-legend-line { + display: inline-block; + width: 12px; + height: 2px; + background: var(--color-primary); + border-radius: 1px; + margin-right: 2px; + margin-left: 8px; +} + +.gas-tendency-chart-legend-bar { + display: inline-block; + width: 8px; + height: 8px; + background: var(--color-primary-alpha-15, rgba(99, 102, 241, 0.15)); + border-radius: 1px; + margin-right: 2px; + margin-left: 8px; +} + +.gas-tendency-chart-loading { + min-height: 140px; +} + +.gas-tendency-chart-skeleton { + width: 100%; + height: 140px; + background: linear-gradient( + 90deg, + var(--overlay-light-6) 0%, + var(--overlay-light-3, rgba(255, 255, 255, 0.04)) 50%, + var(--overlay-light-6) 100% + ); + background-size: 200% 100%; + border-radius: 8px; + animation: gas-tendency-skeleton 1.4s ease-in-out infinite; +} + +@keyframes gas-tendency-skeleton { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} diff --git a/src/types/index.ts b/src/types/index.ts index ff27f57b..cb11010f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -34,6 +34,12 @@ export interface GasPrices { lastBlock: string; // Block number hex } +export interface FeeHistory { + oldestBlock: number; // decimal + baseFeePerGas: bigint[]; // length = blockCount + 1 (wei) + gasUsedRatio: number[]; // length = blockCount, values in 0..1 +} + export interface Block { difficulty: string; extraData: string;