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) => (
+ |
+ ))}
+
+ } />
+
+
+
+
+ {/* 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) => (
+ |
+ ))}
+
+ } />
+
+
+
+
+ {/* 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 };