diff --git a/src/constants/chartColors.ts b/src/constants/chartColors.ts index a295b249..3d0bcc54 100644 --- a/src/constants/chartColors.ts +++ b/src/constants/chartColors.ts @@ -26,3 +26,25 @@ export const CHART_COLORS = { }, }, } as const; + +export const PIE_COLORS = [ + '#3B82F6', // Blue + '#10B981', // Green + '#F59E0B', // Amber + '#8B5CF6', // Violet + '#EC4899', // Pink + '#06B6D4', // Cyan + '#84CC16', // Lime + '#F97316', // Orange + '#6366F1', // Indigo + '#64748B', // Slate (for "Other") +] as const; + +export const RISK_COLORS = { + stroke: '#EF4444', + gradient: { + start: '#EF4444', + startOpacity: 0.3, + endOpacity: 0, + }, +} as const; diff --git a/src/features/market-detail/components/charts/borrowers-pie-chart.tsx b/src/features/market-detail/components/charts/borrowers-pie-chart.tsx new file mode 100644 index 00000000..6d3f174f --- /dev/null +++ b/src/features/market-detail/components/charts/borrowers-pie-chart.tsx @@ -0,0 +1,302 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import type { Address } from 'viem'; +import { formatUnits } from 'viem'; +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts'; +import { Card } from '@/components/ui/card'; +import { Spinner } from '@/components/ui/spinner'; +import { TokenIcon } from '@/components/shared/token-icon'; +import { PIE_COLORS } from '@/constants/chartColors'; +import { useVaultRegistry } from '@/contexts/VaultRegistryContext'; +import { useAllMarketBorrowers } from '@/hooks/useAllMarketPositions'; +import { formatSimple } from '@/utils/balance'; +import { getSlicedAddress } from '@/utils/address'; +import type { SupportedNetworks } from '@/utils/networks'; +import type { Market } from '@/utils/types'; + +type BorrowersPieChartProps = { + chainId: SupportedNetworks; + market: Market; + oraclePrice: bigint; +}; + +type PieDataItem = { + name: string; + value: number; + address: string; + percentage: number; + collateral: number; + ltv: number; + isOther?: boolean; + otherItems?: { name: string; value: number; address: string; percentage: number; collateral: number; ltv: number }[]; +}; + +const TOP_POSITIONS_TO_SHOW = 8; +const OTHER_COLOR = '#64748B'; // Grey for "Other" category + +export function BorrowersPieChart({ chainId, market, oraclePrice }: BorrowersPieChartProps) { + const { data: borrowers, isLoading, totalCount } = useAllMarketBorrowers(market.uniqueKey, chainId); + const { getVaultByAddress } = useVaultRegistry(); + const [expandedOther, setExpandedOther] = useState(false); + + // Helper to get display name for an address (vault name or shortened address) + const getDisplayName = (address: string): string => { + const vault = getVaultByAddress(address as Address, chainId); + if (vault?.name) return vault.name; + return getSlicedAddress(address as `0x${string}`); + }; + + const pieData = useMemo(() => { + if (!borrowers || borrowers.length === 0) return []; + + const totalBorrowAssets = BigInt(market.state.borrowAssets); + + if (totalBorrowAssets === 0n) return []; + + // Calculate data for each borrower and sort by borrow value + const borrowersWithData = borrowers + .map((borrower) => { + const borrowAssets = BigInt(borrower.borrowAssets); + const collateralBigInt = BigInt(borrower.collateral); + + const borrowAssetsNumber = Number(formatUnits(borrowAssets, market.loanAsset.decimals)); + const collateralNumber = Number(formatUnits(collateralBigInt, market.collateralAsset.decimals)); + + // Use scaled bigint math for precision + const percentageScaled = (borrowAssets * 10000n) / totalBorrowAssets; + const percentage = Number(percentageScaled) / 100; + + // Calculate LTV + let ltv = 0; + if (oraclePrice > 0n && collateralBigInt > 0n) { + const collateralValueInLoan = (collateralBigInt * oraclePrice) / BigInt(10 ** 36); + if (collateralValueInLoan > 0n) { + ltv = Number((borrowAssets * 10000n) / collateralValueInLoan) / 100; + } + } + + return { + name: getDisplayName(borrower.userAddress), + address: borrower.userAddress, + value: borrowAssetsNumber, + percentage, + collateral: collateralNumber, + ltv, + }; + }) + .sort((a, b) => b.value - a.value); + + // Split into top positions and "Other" - always show top 8 regardless of percentage + const topPositions: PieDataItem[] = []; + const otherPositions: { + name: string; + value: number; + address: string; + percentage: number; + collateral: number; + ltv: number; + }[] = []; + + for (let i = 0; i < borrowersWithData.length; i++) { + if (i < TOP_POSITIONS_TO_SHOW) { + topPositions.push(borrowersWithData[i]); + } else { + otherPositions.push(borrowersWithData[i]); + } + } + + // Calculate "Other" as everything NOT in topPositions (including positions beyond top 100) + // This correctly accounts for all positions, not just the ones we fetched + const top8TotalPercentage = topPositions.reduce((sum, p) => sum + p.percentage, 0); + const otherPercentage = 100 - top8TotalPercentage; + + // For absolute value, use market total minus top 8 + const totalBorrowValue = Number(formatUnits(totalBorrowAssets, market.loanAsset.decimals)); + const top8TotalValue = topPositions.reduce((sum, p) => sum + p.value, 0); + const otherValue = totalBorrowValue - top8TotalValue; + + // Only add "Other" if there's meaningful remainder + if (otherPercentage > 0.01) { + const otherCollateral = otherPositions.reduce((sum, p) => sum + p.collateral, 0); + + topPositions.push({ + name: 'Other', + address: 'other', + value: otherValue, + percentage: otherPercentage, + collateral: otherCollateral, + ltv: 0, + isOther: true, + otherItems: otherPositions, // Only contains positions 9-100, but percentage/value are correct + }); + } + + return topPositions; + }, [borrowers, market, oraclePrice, getDisplayName]); + + const handlePieClick = (data: PieDataItem) => { + if (data.isOther) { + setExpandedOther(!expandedOther); + } + }; + + // Extract the "Other" entry once for use in expanded section + const otherEntry = useMemo(() => pieData.find((d) => d.isOther), [pieData]); + + // Format percentage display (matches table) + const formatPercentDisplay = (percent: number): string => { + if (percent < 0.01 && percent > 0) return '<0.01%'; + return `${percent.toFixed(2)}%`; + }; + + const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: { payload: PieDataItem }[] }) => { + if (!active || !payload || !payload[0]) return null; + const data = payload[0].payload; + + return ( +
+

{data.name}

+ {!data.isOther &&

{getSlicedAddress(data.address as `0x${string}`)}

} +
+
+ Borrowed +
+ {formatSimple(data.value)} + +
+
+
+ Collateral +
+ {formatSimple(data.collateral)} + +
+
+ {!data.isOther && ( +
+ LTV + {data.ltv.toFixed(2)}% +
+ )} +
+ % of Borrow + {formatPercentDisplay(data.percentage)} +
+
+ {data.isOther &&

Click to {expandedOther ? 'collapse' : 'expand'}

} +
+ ); + }; + + if (isLoading) { + return ( + + + + ); + } + + if (pieData.length === 0) { + return ( + +

No borrowers found

+
+ ); + } + + return ( + +
+
+

Borrow Distribution

+ {totalCount} borrowers +
+
+ +
+ + + handlePieClick(pieData[index])} + style={{ cursor: 'pointer' }} + > + {pieData.map((entry, index) => ( + + ))} + + } /> + ( + + {value} + + )} + /> + + +
+ + {/* Expanded "Other" section */} + {expandedOther && otherEntry?.otherItems && ( +
+
Other Borrowers
+
+
+ {otherEntry.otherItems.slice(0, 20).map((item) => ( +
+ {item.name} + + {formatSimple(item.value)} ({formatPercentDisplay(item.percentage)}) + +
+ ))} + {otherEntry.otherItems.length > 20 && ( +

And {otherEntry.otherItems.length - 20} more...

+ )} +
+
+
+ )} +
+ ); +} diff --git a/src/features/market-detail/components/charts/collateral-at-risk-chart.tsx b/src/features/market-detail/components/charts/collateral-at-risk-chart.tsx new file mode 100644 index 00000000..b70e6b3d --- /dev/null +++ b/src/features/market-detail/components/charts/collateral-at-risk-chart.tsx @@ -0,0 +1,242 @@ +'use client'; + +import { useMemo } from 'react'; +import { formatUnits } from 'viem'; +import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { Card } from '@/components/ui/card'; +import { Spinner } from '@/components/ui/spinner'; +import { RISK_COLORS } from '@/constants/chartColors'; +import { useAllMarketBorrowers } from '@/hooks/useAllMarketPositions'; +import { formatReadable } from '@/utils/balance'; +import type { SupportedNetworks } from '@/utils/networks'; +import type { Market } from '@/utils/types'; +import { ChartGradients, chartTooltipCursor } from './chart-utils'; + +type CollateralAtRiskChartProps = { + chainId: SupportedNetworks; + market: Market; + oraclePrice: bigint; +}; + +type RiskDataPoint = { + priceDrop: number; // 0 to -100 + cumulativeDebt: number; // Absolute debt amount + cumulativeDebtPercent: number; // Percentage of total debt +}; + +// Gradient config for the risk chart +const RISK_GRADIENTS = [{ id: 'riskGradient', color: RISK_COLORS.stroke }]; + +export function CollateralAtRiskChart({ chainId, market, oraclePrice }: CollateralAtRiskChartProps) { + const { data: borrowers, isLoading } = useAllMarketBorrowers(market.uniqueKey, chainId); + + const lltv = useMemo(() => { + const lltvBigInt = BigInt(market.lltv); + return Number(lltvBigInt) / 1e18; // LLTV as decimal (e.g., 0.8 for 80%) + }, [market.lltv]); + + const { chartData, totalDebt, riskMetrics } = useMemo(() => { + if (!borrowers || borrowers.length === 0 || !oraclePrice || oraclePrice === 0n || lltv === 0) { + return { chartData: [], totalDebt: 0, riskMetrics: null }; + } + + // Calculate price drop threshold for each borrower + const borrowersWithRisk = borrowers + .map((borrower) => { + const borrowAssets = BigInt(borrower.borrowAssets); + const collateral = BigInt(borrower.collateral); + + if (collateral === 0n || borrowAssets === 0n) return null; + + // Calculate collateral value in loan asset terms + const collateralValueInLoan = (collateral * oraclePrice) / BigInt(10 ** 36); + if (collateralValueInLoan === 0n) return null; + + // Calculate current LTV as decimal + const currentLTV = Number(borrowAssets) / Number(collateralValueInLoan); + const debtValue = Number(formatUnits(borrowAssets, market.loanAsset.decimals)); + + // Calculate price drop needed for liquidation + // Formula: priceDropPercent = 100 * (1 - currentLTV / LLTV) + // Negative value means price needs to DROP by that percentage + const priceDropForLiquidation = -100 * (1 - currentLTV / lltv); + + // If currentLTV >= LLTV, priceDropForLiquidation >= 0 (already at/past threshold) + // Clamp to reasonable range + const clampedPriceDrop = Math.max(-100, Math.min(0, priceDropForLiquidation)); + + return { priceDrop: clampedPriceDrop, debt: debtValue }; + }) + .filter((b): b is { priceDrop: number; debt: number } => b !== null) + .sort((a, b) => b.priceDrop - a.priceDrop); // Sort by price drop descending (closest to 0 first = most at risk) + + if (borrowersWithRisk.length === 0) { + return { chartData: [], totalDebt: 0, riskMetrics: null }; + } + + // Calculate total debt + const total = borrowersWithRisk.reduce((sum, b) => sum + b.debt, 0); + + // Build data points only at actual liquidation thresholds (where debt changes) + // Get unique price drop thresholds, plus start (0) and end (-100) + const thresholds = new Set([0, -100, ...borrowersWithRisk.map((b) => Math.round(b.priceDrop))]); + const sortedThresholds = [...thresholds].sort((a, b) => b - a); // Descending (0 to -100) + + const dataPoints: RiskDataPoint[] = sortedThresholds.map((drop) => { + const cumulativeDebt = borrowersWithRisk.filter((b) => b.priceDrop >= drop).reduce((sum, b) => sum + b.debt, 0); + const cumulativeDebtPercent = total > 0 ? (cumulativeDebt / total) * 100 : 0; + return { priceDrop: drop, cumulativeDebt, cumulativeDebtPercent }; + }); + + // Calculate risk metrics for header (percentages) - interpolate from nearest threshold + const getPercentAtDrop = (targetDrop: number) => { + const point = dataPoints.find((d) => d.priceDrop <= targetDrop); + return point?.cumulativeDebtPercent ?? 0; + }; + const percentAt10 = getPercentAtDrop(-10); + const percentAt25 = getPercentAtDrop(-25); + const percentAt50 = getPercentAtDrop(-50); + + return { + chartData: dataPoints, + totalDebt: total, + riskMetrics: { + percentAt10, + percentAt25, + percentAt50, + }, + }; + }, [borrowers, oraclePrice, market.loanAsset.decimals, lltv]); + + const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: { value: number; payload: RiskDataPoint }[] }) => { + if (!active || !payload || !payload[0]) return null; + const data = payload[0].payload; + + return ( +
+

At {data.priceDrop}% price drop

+
+
+ % of Total Debt + {data.cumulativeDebtPercent.toFixed(1)}% +
+
+ Debt at Risk + + {formatReadable(data.cumulativeDebt)} {market.loanAsset.symbol} + +
+
+
+ ); + }; + + if (isLoading) { + return ( + + + + ); + } + + if (chartData.length === 0) { + return ( + +

No borrower data available

+
+ ); + } + + return ( + +
+
+

Collateral at Risk

+ {riskMetrics && ( +
+
+ @-10%: + 0 ? RISK_COLORS.stroke : 'inherit' }} + > + {riskMetrics.percentAt10.toFixed(1)}% + +
+
+ @-25%: + {riskMetrics.percentAt25.toFixed(1)}% +
+
+ @-50%: + {riskMetrics.percentAt50.toFixed(1)}% +
+
+ )} +
+
+ +
+ + + + + `${value}%`} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + domain={[-100, 0]} + ticks={[0, -20, -40, -60, -80, -100]} + reversed + /> + `${value}%`} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + width={50} + domain={[0, 100]} + /> + } + /> + + + +
+ +
+

+ Shows cumulative debt at risk if collateral price drops. Total debt: {formatReadable(totalDebt)} {market.loanAsset.symbol}. LLTV:{' '} + {(lltv * 100).toFixed(0)}% +

+
+
+ ); +} diff --git a/src/features/market-detail/components/charts/concentration-chart.tsx b/src/features/market-detail/components/charts/concentration-chart.tsx new file mode 100644 index 00000000..8fedd541 --- /dev/null +++ b/src/features/market-detail/components/charts/concentration-chart.tsx @@ -0,0 +1,235 @@ +'use client'; + +import { useMemo } from 'react'; +import { Area, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { Card } from '@/components/ui/card'; +import { Spinner } from '@/components/ui/spinner'; +import { ChartGradients, chartTooltipCursor } from './chart-utils'; + +type ConcentrationDataPoint = { + position: number; + cumulativePercent: number; + idealPercent: number; +}; + +type ConcentrationChartProps = { + positions: { percentage: number }[] | null; + totalCount: number; + isLoading: boolean; + title: string; + color: string; +}; + +const MIN_PERCENT_THRESHOLD = 0.1; + +export function ConcentrationChart({ positions, totalCount, isLoading, title, color }: ConcentrationChartProps) { + const { chartData, meaningfulCount, totalPercentShown } = useMemo(() => { + const emptyResult = { chartData: [], meaningfulCount: 0, totalPercentShown: 0 }; + + if (!positions || positions.length === 0 || totalCount === 0) { + return emptyResult; + } + + const sorted = [...positions].sort((a, b) => b.percentage - a.percentage); + const meaningful = sorted.filter((p) => p.percentage > 0 && p.percentage >= MIN_PERCENT_THRESHOLD); + + if (meaningful.length === 0) { + return emptyResult; + } + + let runningSum = 0; + const dataPoints: ConcentrationDataPoint[] = meaningful.map((pos, index) => { + runningSum += pos.percentage; + return { + position: index + 1, + cumulativePercent: runningSum, + idealPercent: 0, + }; + }); + + const totalPct = runningSum; + for (const point of dataPoints) { + point.idealPercent = (point.position / meaningful.length) * totalPct; + } + + if (meaningful.length > 1) { + dataPoints.unshift({ position: 0, cumulativePercent: 0, idealPercent: 0 }); + } + + return { + chartData: dataPoints, + meaningfulCount: meaningful.length, + totalPercentShown: totalPct, + }; + }, [positions, totalCount]); + + const metrics = useMemo(() => { + if (chartData.length === 0) return null; + + const findPercent = (pos: number) => chartData.find((d) => d.position === pos)?.cumulativePercent ?? null; + + return { + top1: findPercent(1), + top10: findPercent(10), + top100: findPercent(100), + }; + }, [chartData]); + + function CustomTooltip({ active, payload }: { active?: boolean; payload?: { payload: ConcentrationDataPoint }[] }) { + if (!active || !payload?.[0]) return null; + const data = payload[0].payload; + + if (data.position === 0) return null; + + return ( +
+

Top {data.position.toLocaleString()}

+
+
+ Actual + {data.cumulativePercent.toFixed(1)}% +
+
+ If equal + {data.idealPercent.toFixed(1)}% +
+
+
+ ); + } + + if (isLoading) { + return ( + + + + ); + } + + if (!positions || positions.length === 0) { + return ( + +

No data available

+
+ ); + } + + const yAxisMax = Math.ceil(totalPercentShown / 10) * 10 || 100; + + if (chartData.length === 0) { + return ( + +
+

{title}

+
+
+

All positions below {MIN_PERCENT_THRESHOLD}% threshold

+
+
+ ); + } + + return ( + +
+
+

{title}

+ {metrics && ( +
+ {metrics.top1 !== null && ( +
+ Top 1: + {metrics.top1.toFixed(1)}% +
+ )} + {metrics.top10 !== null && ( +
+ Top 10: + {metrics.top10.toFixed(1)}% +
+ )} + {metrics.top100 !== null && ( +
+ Top 100: + {metrics.top100.toFixed(1)}% +
+ )} +
+ )} +
+
+ +
+ + + + + value.toLocaleString()} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + /> + `${value}%`} + tick={{ fontSize: 11, fill: 'var(--color-text-secondary)' }} + width={45} + domain={[0, yAxisMax]} + /> + } + /> + + + + +
+ +
+

+ {meaningfulCount < totalCount + ? `${meaningfulCount.toLocaleString()} of ${totalCount.toLocaleString()} positions above ${MIN_PERCENT_THRESHOLD}%` + : `${totalCount.toLocaleString()} total positions`} +

+
+
+ ); +} diff --git a/src/features/market-detail/components/charts/suppliers-pie-chart.tsx b/src/features/market-detail/components/charts/suppliers-pie-chart.tsx new file mode 100644 index 00000000..8dc3e65c --- /dev/null +++ b/src/features/market-detail/components/charts/suppliers-pie-chart.tsx @@ -0,0 +1,256 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import type { Address } from 'viem'; +import { formatUnits } from 'viem'; +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts'; +import { Card } from '@/components/ui/card'; +import { Spinner } from '@/components/ui/spinner'; +import { TokenIcon } from '@/components/shared/token-icon'; +import { PIE_COLORS } from '@/constants/chartColors'; +import { useVaultRegistry } from '@/contexts/VaultRegistryContext'; +import { useAllMarketSuppliers } from '@/hooks/useAllMarketPositions'; +import { formatSimple } from '@/utils/balance'; +import { getSlicedAddress } from '@/utils/address'; +import type { SupportedNetworks } from '@/utils/networks'; +import type { Market } from '@/utils/types'; + +type SuppliersPieChartProps = { + chainId: SupportedNetworks; + market: Market; +}; + +type PieDataItem = { + name: string; + value: number; + address: string; + percentage: number; + isOther?: boolean; + otherItems?: { name: string; value: number; address: string; percentage: number }[]; +}; + +const TOP_POSITIONS_TO_SHOW = 8; +const OTHER_COLOR = '#64748B'; // Grey for "Other" category + +export function SuppliersPieChart({ chainId, market }: SuppliersPieChartProps) { + const { data: suppliers, isLoading, totalCount } = useAllMarketSuppliers(market.uniqueKey, chainId); + const { getVaultByAddress } = useVaultRegistry(); + const [expandedOther, setExpandedOther] = useState(false); + + // Helper to get display name for an address (vault name or shortened address) + const getDisplayName = (address: string): string => { + const vault = getVaultByAddress(address as Address, chainId); + if (vault?.name) return vault.name; + return getSlicedAddress(address as `0x${string}`); + }; + + const pieData = useMemo(() => { + if (!suppliers || suppliers.length === 0) return []; + + const totalSupplyShares = BigInt(market.state.supplyShares); + const totalSupplyAssets = BigInt(market.state.supplyAssets); + + if (totalSupplyShares === 0n) return []; + + // Calculate assets for each supplier and sort by value + const suppliersWithAssets = suppliers + .map((supplier) => { + const shares = BigInt(supplier.supplyShares); + const assets = (shares * totalSupplyAssets) / totalSupplyShares; + const assetsNumber = Number(formatUnits(assets, market.loanAsset.decimals)); + // Use scaled bigint math for precision + const percentageScaled = (shares * 10000n) / totalSupplyShares; + const percentage = Number(percentageScaled) / 100; + + return { + name: getDisplayName(supplier.userAddress), + address: supplier.userAddress, + value: assetsNumber, + percentage, + }; + }) + .sort((a, b) => b.value - a.value); + + // Split into top positions and "Other" - always show top 8 regardless of percentage + const topPositions: PieDataItem[] = []; + const otherPositions: { name: string; value: number; address: string; percentage: number }[] = []; + + for (let i = 0; i < suppliersWithAssets.length; i++) { + if (i < TOP_POSITIONS_TO_SHOW) { + topPositions.push(suppliersWithAssets[i]); + } else { + otherPositions.push(suppliersWithAssets[i]); + } + } + + // Calculate "Other" as everything NOT in topPositions (including positions beyond top 100) + // This correctly accounts for all positions, not just the ones we fetched + const top8TotalPercentage = topPositions.reduce((sum, p) => sum + p.percentage, 0); + const otherPercentage = 100 - top8TotalPercentage; + + // For absolute value, use market total minus top 8 + const totalSupplyValue = Number(formatUnits(totalSupplyAssets, market.loanAsset.decimals)); + const top8TotalValue = topPositions.reduce((sum, p) => sum + p.value, 0); + const otherValue = totalSupplyValue - top8TotalValue; + + // Only add "Other" if there's meaningful remainder + if (otherPercentage > 0.01) { + topPositions.push({ + name: 'Other', + address: 'other', + value: otherValue, + percentage: otherPercentage, + isOther: true, + otherItems: otherPositions, // Only contains positions 9-100, but percentage/value are correct + }); + } + + return topPositions; + }, [suppliers, market, getDisplayName]); + + const handlePieClick = (data: PieDataItem) => { + if (data.isOther) { + setExpandedOther(!expandedOther); + } + }; + + // Extract the "Other" entry once for use in expanded section + const otherEntry = useMemo(() => pieData.find((d) => d.isOther), [pieData]); + + // Format percentage display (matches table) + const formatPercentDisplay = (percent: number): string => { + if (percent < 0.01 && percent > 0) return '<0.01%'; + return `${percent.toFixed(2)}%`; + }; + + const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: { payload: PieDataItem }[] }) => { + if (!active || !payload || !payload[0]) return null; + const data = payload[0].payload; + + return ( +
+

{data.name}

+ {!data.isOther &&

{getSlicedAddress(data.address as `0x${string}`)}

} +
+
+ Supplied +
+ {formatSimple(data.value)} + +
+
+
+ % of Supply + {formatPercentDisplay(data.percentage)} +
+
+ {data.isOther &&

Click to {expandedOther ? 'collapse' : 'expand'}

} +
+ ); + }; + + if (isLoading) { + return ( + + + + ); + } + + if (pieData.length === 0) { + return ( + +

No suppliers found

+
+ ); + } + + return ( + +
+
+

Supply Distribution

+ {totalCount} suppliers +
+
+ +
+ + + handlePieClick(pieData[index])} + style={{ cursor: 'pointer' }} + > + {pieData.map((entry, index) => ( + + ))} + + } /> + ( + + {value} + + )} + /> + + +
+ + {/* Expanded "Other" section */} + {expandedOther && otherEntry?.otherItems && ( +
+
Other Suppliers
+
+
+ {otherEntry.otherItems.slice(0, 20).map((item) => ( +
+ {item.name} + + {formatSimple(item.value)} ({formatPercentDisplay(item.percentage)}) + +
+ ))} + {otherEntry.otherItems.length > 20 && ( +

And {otherEntry.otherItems.length - 20} more...

+ )} +
+
+
+ )} +
+ ); +} diff --git a/src/features/market-detail/market-view.tsx b/src/features/market-detail/market-view.tsx index 31ff4d15..8963e230 100644 --- a/src/features/market-detail/market-view.tsx +++ b/src/features/market-detail/market-view.tsx @@ -14,6 +14,7 @@ import { useModal } from '@/hooks/useModal'; import { useMarketData } from '@/hooks/useMarketData'; import { useOraclePrice } from '@/hooks/useOraclePrice'; import { useTransactionFilters } from '@/stores/useTransactionFilters'; +import { useMarketDetailPreferences, type MarketDetailTab } from '@/stores/useMarketDetailPreferences'; import useUserPosition from '@/hooks/useUserPosition'; import type { SupportedNetworks } from '@/utils/networks'; import { BorrowersTable } from '@/features/market-detail/components/borrowers-table'; @@ -25,9 +26,15 @@ import { SuppliersTable } from '@/features/market-detail/components/suppliers-ta import SupplierFiltersModal from '@/features/market-detail/components/filters/supplier-filters-modal'; import TransactionFiltersModal from '@/features/market-detail/components/filters/transaction-filters-modal'; import { useMarketWarnings } from '@/hooks/useMarketWarnings'; +import { useAllMarketBorrowers, useAllMarketSuppliers } from '@/hooks/useAllMarketPositions'; import { MarketHeader } from './components/market-header'; import RateChart from './components/charts/rate-chart'; import VolumeChart from './components/charts/volume-chart'; +import { SuppliersPieChart } from './components/charts/suppliers-pie-chart'; +import { BorrowersPieChart } from './components/charts/borrowers-pie-chart'; +import { CollateralAtRiskChart } from './components/charts/collateral-at-risk-chart'; +import { ConcentrationChart } from './components/charts/concentration-chart'; +import { CHART_COLORS } from '@/constants/chartColors'; function MarketContent() { // 1. Get URL params first @@ -38,13 +45,15 @@ function MarketContent() { // 3. Consolidated state const { open: openModal } = useModal(); + const selectedTab = useMarketDetailPreferences((s) => s.selectedTab); + const setSelectedTab = useMarketDetailPreferences((s) => s.setSelectedTab); const [showBorrowModal, setShowBorrowModal] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const [showTransactionFiltersModal, setShowTransactionFiltersModal] = useState(false); const [showSupplierFiltersModal, setShowSupplierFiltersModal] = useState(false); - const [minSupplierShares, setMinSupplierShares] = useState('0'); + const [minSupplierShares, setMinSupplierShares] = useState(''); const [showBorrowerFiltersModal, setShowBorrowerFiltersModal] = useState(false); - const [minBorrowerShares, setMinBorrowerShares] = useState('0'); + const [minBorrowerShares, setMinBorrowerShares] = useState(''); // 4. Data fetching hooks - use unified time range const { @@ -72,6 +81,18 @@ function MarketContent() { // Get all warnings for this market (hook handles undefined market) const allWarnings = useMarketWarnings(market); + // Fetch position data for concentration charts + const { + data: borrowersData, + isLoading: borrowersLoading, + totalCount: borrowersTotalCount, + } = useAllMarketBorrowers(market?.uniqueKey, network); + const { + data: suppliersData, + isLoading: suppliersLoading, + totalCount: suppliersTotalCount, + } = useAllMarketSuppliers(market?.uniqueKey, network); + // 6. All memoized values and callbacks // Helper to scale user input to token amount @@ -124,7 +145,7 @@ function MarketContent() { BigInt(market.state.supplyShares), market.loanAsset.decimals, ) - : '0', + : '1', [minSupplierShares, market], ); @@ -137,10 +158,38 @@ function MarketContent() { BigInt(market.state.borrowShares), market.loanAsset.decimals, ) - : '0', + : '1', [minBorrowerShares, market], ); + // Prepare concentration data for borrowers + const borrowerConcentrationData = useMemo(() => { + if (!borrowersData || borrowersData.length === 0) return null; + // Calculate total from actual data to ensure percentages sum to 100% + const totalBorrowAssets = borrowersData.reduce((sum, b) => sum + BigInt(b.borrowAssets), 0n); + if (totalBorrowAssets === 0n) return null; + + return borrowersData.map((b) => { + const borrowAssets = BigInt(b.borrowAssets); + const percentageScaled = (borrowAssets * 10000n) / totalBorrowAssets; + return { percentage: Number(percentageScaled) / 100 }; + }); + }, [borrowersData]); + + // Prepare concentration data for suppliers + const supplierConcentrationData = useMemo(() => { + if (!suppliersData || suppliersData.length === 0) return null; + // Calculate total from actual data to ensure percentages sum to 100% + const totalSupplyShares = suppliersData.reduce((sum, s) => sum + BigInt(s.supplyShares), 0n); + if (totalSupplyShares === 0n) return null; + + return suppliersData.map((s) => { + const shares = BigInt(s.supplyShares); + const percentageScaled = (shares * 10000n) / totalSupplyShares; + return { percentage: Number(percentageScaled) / 100 }; + }); + }, [suppliersData]); + // Unified refetch function for both market and user position const handleRefreshAll = useCallback(async () => { setIsRefreshing(true); @@ -256,16 +305,17 @@ function MarketContent() { {/* Tabs Section */} setSelectedTab(value as MarketDetailTab)} className="mt-8 w-full" > - Statistics + Trend Activities Positions - + - setShowSupplierFiltersModal(true)} - /> + {/* Suppliers row: Pie + Concentration */} +
+ + +
+ + {/* Borrowers row: Pie + Concentration */} +
+ + +
+ + {/* Collateral at Risk chart */} +
+ +
+ + {/* Tables */} +
+ setShowSupplierFiltersModal(true)} + /> +
{ + const { data, isLoading, error } = useQuery({ + queryKey: ['allMarketBorrowers', marketId, network], + queryFn: async () => { + if (!marketId || !network) return null; + + // Try Morpho API first + if (supportsMorphoApi(network)) { + try { + const result = await fetchMorphoMarketBorrowers(marketId, Number(network), '1', TOP_POSITIONS_LIMIT, 0); + return result; + } catch (morphoError) { + console.error('Failed to fetch all borrowers via Morpho API:', morphoError); + } + } + + // Fallback to Subgraph + const result = await fetchSubgraphMarketBorrowers(marketId, network, '1', TOP_POSITIONS_LIMIT, 0); + return result; + }, + enabled: !!marketId && !!network, + staleTime: 1000 * 60 * 2, // 2 minutes + }); + + return { + data: data?.items ?? null, + totalCount: data?.totalCount ?? 0, + isLoading, + error: error as Error | null, + }; +}; + +/** + * Fetches top suppliers for chart aggregation (non-paginated). + * Retrieves up to 1000 positions sorted by supply shares descending. + */ +export const useAllMarketSuppliers = (marketId: string | undefined, network: SupportedNetworks | undefined): UseAllSuppliersResult => { + const { data, isLoading, error } = useQuery({ + queryKey: ['allMarketSuppliers', marketId, network], + queryFn: async () => { + if (!marketId || !network) return null; + + // Try Morpho API first + if (supportsMorphoApi(network)) { + try { + const result = await fetchMorphoMarketSuppliers(marketId, Number(network), '1', TOP_POSITIONS_LIMIT, 0); + return result; + } catch (morphoError) { + console.error('Failed to fetch all suppliers via Morpho API:', morphoError); + } + } + + // Fallback to Subgraph + const result = await fetchSubgraphMarketSuppliers(marketId, network, '1', TOP_POSITIONS_LIMIT, 0); + return result; + }, + enabled: !!marketId && !!network, + staleTime: 1000 * 60 * 2, // 2 minutes + }); + + return { + data: data?.items ?? null, + totalCount: data?.totalCount ?? 0, + isLoading, + error: error as Error | null, + }; +}; diff --git a/src/hooks/useMarketBorrowers.ts b/src/hooks/useMarketBorrowers.ts index 674fe790..171006a7 100644 --- a/src/hooks/useMarketBorrowers.ts +++ b/src/hooks/useMarketBorrowers.ts @@ -14,7 +14,7 @@ import type { PaginatedMarketBorrowers } from '@/utils/types'; * * @param marketId The ID of the market (e.g., 0x...). * @param network The blockchain network. - * @param minShares Minimum borrow share amount to filter borrowers (optional, defaults to '0'). + * @param minShares Minimum borrow share amount to filter borrowers (optional, defaults to '1' to exclude zero positions). * @param page Current page number (1-indexed, defaults to 1). * @param pageSize Number of items per page (defaults to 10). * @returns Paginated borrowers for the market. @@ -22,13 +22,16 @@ import type { PaginatedMarketBorrowers } from '@/utils/types'; export const useMarketBorrowers = ( marketId: string | undefined, network: SupportedNetworks | undefined, - minShares = '0', + minShares = '1', page = 1, pageSize = 10, ) => { const queryClient = useQueryClient(); - const queryKey = ['marketBorrowers', marketId, network, minShares, page, pageSize]; + // Always filter out zero positions by ensuring minShares >= 1 + const effectiveMinShares = !minShares || minShares === '0' || minShares === '' ? '1' : minShares; + + const queryKey = ['marketBorrowers', marketId, network, effectiveMinShares, page, pageSize]; const queryFn = useCallback( async (targetPage: number): Promise => { @@ -43,7 +46,7 @@ export const useMarketBorrowers = ( if (supportsMorphoApi(network)) { try { console.log(`Attempting to fetch borrowers via Morpho API for ${marketId} (page ${targetPage})`); - result = await fetchMorphoMarketBorrowers(marketId, Number(network), minShares, pageSize, targetSkip); + result = await fetchMorphoMarketBorrowers(marketId, Number(network), effectiveMinShares, pageSize, targetSkip); } catch (morphoError) { console.error('Failed to fetch borrowers via Morpho API:', morphoError); } @@ -53,7 +56,7 @@ export const useMarketBorrowers = ( if (!result) { try { console.log(`Attempting to fetch borrowers via Subgraph for ${marketId} (page ${targetPage})`); - result = await fetchSubgraphMarketBorrowers(marketId, network, minShares, pageSize, targetSkip); + result = await fetchSubgraphMarketBorrowers(marketId, network, effectiveMinShares, pageSize, targetSkip); } catch (subgraphError) { console.error('Failed to fetch borrowers via Subgraph:', subgraphError); throw subgraphError; @@ -62,7 +65,7 @@ export const useMarketBorrowers = ( return result; }, - [marketId, network, minShares, pageSize], + [marketId, network, effectiveMinShares, pageSize], ); const { data, isLoading, isFetching, error, refetch } = useQuery({ @@ -81,7 +84,7 @@ export const useMarketBorrowers = ( const totalPages = data.totalCount > 0 ? Math.ceil(data.totalCount / pageSize) : 0; if (page > 1) { - const prevPageKey = ['marketBorrowers', marketId, network, minShares, page - 1, pageSize]; + const prevPageKey = ['marketBorrowers', marketId, network, effectiveMinShares, page - 1, pageSize]; void queryClient.prefetchQuery({ queryKey: prevPageKey, queryFn: async () => queryFn(page - 1), @@ -90,14 +93,14 @@ export const useMarketBorrowers = ( } if (page < totalPages) { - const nextPageKey = ['marketBorrowers', marketId, network, minShares, page + 1, pageSize]; + const nextPageKey = ['marketBorrowers', marketId, network, effectiveMinShares, page + 1, pageSize]; void queryClient.prefetchQuery({ queryKey: nextPageKey, queryFn: async () => queryFn(page + 1), staleTime: 1000 * 60 * 2, }); } - }, [page, data, queryClient, queryFn, marketId, network, minShares, pageSize]); + }, [page, data, queryClient, queryFn, marketId, network, effectiveMinShares, pageSize]); return { data: data, diff --git a/src/hooks/useMarketSuppliers.ts b/src/hooks/useMarketSuppliers.ts index 1115aefd..ccaa1700 100644 --- a/src/hooks/useMarketSuppliers.ts +++ b/src/hooks/useMarketSuppliers.ts @@ -14,7 +14,7 @@ import type { PaginatedMarketSuppliers } from '@/utils/types'; * * @param marketId The ID of the market (e.g., 0x...). * @param network The blockchain network. - * @param minShares Minimum share amount to filter suppliers (optional, defaults to '0'). + * @param minShares Minimum share amount to filter suppliers (optional, defaults to '1' to exclude zero positions). * @param page Current page number (1-indexed, defaults to 1). * @param pageSize Number of items per page (defaults to 8). * @returns Paginated suppliers for the market. @@ -22,13 +22,16 @@ import type { PaginatedMarketSuppliers } from '@/utils/types'; export const useMarketSuppliers = ( marketId: string | undefined, network: SupportedNetworks | undefined, - minShares = '0', + minShares = '1', page = 1, pageSize = 8, ) => { const queryClient = useQueryClient(); - const queryKey = ['marketSuppliers', marketId, network, minShares, page, pageSize]; + // Always filter out zero positions by ensuring minShares >= 1 + const effectiveMinShares = !minShares || minShares === '0' || minShares === '' ? '1' : minShares; + + const queryKey = ['marketSuppliers', marketId, network, effectiveMinShares, page, pageSize]; const queryFn = useCallback( async (targetPage: number): Promise => { @@ -43,7 +46,7 @@ export const useMarketSuppliers = ( if (supportsMorphoApi(network)) { try { console.log(`Attempting to fetch suppliers via Morpho API for ${marketId} (page ${targetPage})`); - result = await fetchMorphoMarketSuppliers(marketId, Number(network), minShares, pageSize, targetSkip); + result = await fetchMorphoMarketSuppliers(marketId, Number(network), effectiveMinShares, pageSize, targetSkip); } catch (morphoError) { console.error('Failed to fetch suppliers via Morpho API:', morphoError); } @@ -53,7 +56,7 @@ export const useMarketSuppliers = ( if (!result) { try { console.log(`Attempting to fetch suppliers via Subgraph for ${marketId} (page ${targetPage})`); - result = await fetchSubgraphMarketSuppliers(marketId, network, minShares, pageSize, targetSkip); + result = await fetchSubgraphMarketSuppliers(marketId, network, effectiveMinShares, pageSize, targetSkip); } catch (subgraphError) { console.error('Failed to fetch suppliers via Subgraph:', subgraphError); throw subgraphError; @@ -62,7 +65,7 @@ export const useMarketSuppliers = ( return result; }, - [marketId, network, minShares, pageSize], + [marketId, network, effectiveMinShares, pageSize], ); const { data, isLoading, isFetching, error, refetch } = useQuery({ @@ -81,7 +84,7 @@ export const useMarketSuppliers = ( const totalPages = data.totalCount > 0 ? Math.ceil(data.totalCount / pageSize) : 0; if (page > 1) { - const prevPageKey = ['marketSuppliers', marketId, network, minShares, page - 1, pageSize]; + const prevPageKey = ['marketSuppliers', marketId, network, effectiveMinShares, page - 1, pageSize]; void queryClient.prefetchQuery({ queryKey: prevPageKey, queryFn: async () => queryFn(page - 1), @@ -90,14 +93,14 @@ export const useMarketSuppliers = ( } if (page < totalPages) { - const nextPageKey = ['marketSuppliers', marketId, network, minShares, page + 1, pageSize]; + const nextPageKey = ['marketSuppliers', marketId, network, effectiveMinShares, page + 1, pageSize]; void queryClient.prefetchQuery({ queryKey: nextPageKey, queryFn: async () => queryFn(page + 1), staleTime: 1000 * 60 * 2, }); } - }, [page, data, queryClient, queryFn, marketId, network, minShares, pageSize]); + }, [page, data, queryClient, queryFn, marketId, network, effectiveMinShares, pageSize]); return { data: data, diff --git a/src/stores/useMarketDetailPreferences.ts b/src/stores/useMarketDetailPreferences.ts new file mode 100644 index 00000000..14f72047 --- /dev/null +++ b/src/stores/useMarketDetailPreferences.ts @@ -0,0 +1,30 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +type MarketDetailTab = 'trend' | 'activities' | 'positions'; + +type MarketDetailPreferencesState = { + selectedTab: MarketDetailTab; +}; + +type MarketDetailPreferencesActions = { + setSelectedTab: (tab: MarketDetailTab) => void; + setAll: (state: Partial) => void; +}; + +type MarketDetailPreferencesStore = MarketDetailPreferencesState & MarketDetailPreferencesActions; + +export const useMarketDetailPreferences = create()( + persist( + (set) => ({ + selectedTab: 'trend', + setSelectedTab: (tab) => set({ selectedTab: tab }), + setAll: (state) => set(state), + }), + { + name: 'monarch_store_marketDetailPreferences', + }, + ), +); + +export type { MarketDetailTab };