From 35b8515d1586f77b632393a642d3bc11514cf692 Mon Sep 17 00:00:00 2001 From: novi Date: Tue, 20 Jan 2026 20:35:19 +0200 Subject: [PATCH 1/3] implement bridge route on dashboard --- package.json | 2 + src/app/page.tsx | 7 + src/components/chart/bridge-route-map.tsx | 493 ++++++++++++++++++++++ src/pages/api/routes.ts | 110 +++++ src/utils/axios.ts | 1 + src/utils/types.ts | 15 + yarn.lock | 318 ++++++++++++++ 7 files changed, 946 insertions(+) create mode 100644 src/components/chart/bridge-route-map.tsx create mode 100644 src/pages/api/routes.ts diff --git a/package.json b/package.json index e70ee3f..3a975bf 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,8 @@ "@mui/x-date-pickers": "^7.13.0", "@mui/x-tree-view": "^7.13.0", "@mysten/dapp-kit": "^0.16.15", + "@nivo/core": "^0.99.0", + "@nivo/sankey": "^0.99.0", "@rainbow-me/rainbowkit": "^2.2.8", "@tanstack/react-query": "^5.83.0", "apexcharts": "^4.0.0", diff --git a/src/app/page.tsx b/src/app/page.tsx index 39ee758..8552d37 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -9,6 +9,7 @@ import { DashboardContent, DashboardLayout } from 'src/layouts/dashboard' const InflowOutflowCharts = lazy(() => import('src/components/chart/inflow-outflow-charts')) const TokenVolumePieChart = lazy(() => import('src/components/chart/pie-charts')) const GasUsageChart = lazy(() => import('src/components/chart/gas-usage-chart')) +const BridgeRouteMap = lazy(() => import('src/components/chart/bridge-route-map')) import BridgePerformanceChart from 'src/components/chart/bridge-performance-chart' import CumulativeNetInflow from 'src/components/chart/cumulative-net-inflow' import TopTokens from 'src/components/widgets/top-tokens' @@ -23,6 +24,12 @@ export default function Page() { + + {/* Bridge Route Map - Sankey visualization */} + }> + + + {/* Latest Transactions - also use visibility system */} diff --git a/src/components/chart/bridge-route-map.tsx b/src/components/chart/bridge-route-map.tsx new file mode 100644 index 0000000..5797332 --- /dev/null +++ b/src/components/chart/bridge-route-map.tsx @@ -0,0 +1,493 @@ +'use client' + +import { useState, useMemo } from 'react' +import dynamic from 'next/dynamic' +import { + Card, + CardHeader, + Box, + ToggleButton, + ToggleButtonGroup, + Typography, + Stack, + Skeleton, +} from '@mui/material' +import { useTheme, alpha } from '@mui/material/styles' +import useSWR from 'swr' +import { useGlobalContext } from 'src/provider/global-provider' +import { getNetwork } from 'src/hooks/get-network-storage' +import { endpoints, fetcher } from 'src/utils/axios' +import { RouteMapRow, getTokensList } from 'src/utils/types' +import { fNumber } from 'src/utils/format-number' + +// Dynamic import for Sankey to avoid SSR issues +const ResponsiveSankey = dynamic(() => import('@nivo/sankey').then(mod => mod.ResponsiveSankey), { + ssr: false, +}) + +type ValueType = 'usd' | 'count' + +// Chain colors - more vibrant +const CHAIN_COLORS: Record = { + SUI: '#4da2ff', + ETH: '#8c7cf0', +} + +// Helper to strip prefix from node ID for display +const getDisplayLabel = (id: string): string => { + if (id.startsWith('from:')) return id.slice(5) + if (id.startsWith('to:')) return id.slice(3) + if (id.startsWith('token:')) return id.slice(6) + return id +} + +// Helper to check if a node matches the highlighted item +const isNodeHighlightMatch = (nodeId: string, highlighted: string): boolean => { + if (highlighted.startsWith('chain:')) { + // Highlighting a chain - match both from: and to: nodes for that chain + const chainName = highlighted.slice(6) + return nodeId === `from:${chainName}` || nodeId === `to:${chainName}` + } + if (highlighted.startsWith('token:')) { + // Highlighting a token - match the token node + return nodeId === highlighted + } + return false +} + +// Helper to check if a link matches the highlighted item +const isLinkHighlightMatch = (source: string, target: string, highlighted: string): boolean => { + return isNodeHighlightMatch(source, highlighted) || isNodeHighlightMatch(target, highlighted) +} + +interface SankeyNode { + id: string + nodeColor: string +} + +interface SankeyLink { + source: string + target: string + value: number +} + +interface SankeyData { + nodes: SankeyNode[] + links: SankeyLink[] +} + +export default function BridgeRouteMap() { + const theme = useTheme() + const network = getNetwork() + const { timePeriod, selectedTokens } = useGlobalContext() + const [valueType, setValueType] = useState('usd') + const [highlightedItem, setHighlightedItem] = useState(null) + + const tokensList = getTokensList(network) + + // Fetch route data + const { data, isLoading } = useSWR( + `${endpoints.routes}?network=${network}&timePeriod=${encodeURIComponent(timePeriod)}`, + fetcher, + { revalidateOnFocus: false }, + ) + + // Filter data by selected tokens + const filteredData = useMemo(() => { + if (!data) return [] + if (selectedTokens.includes('All')) return data + return data.filter(row => selectedTokens.includes(row.token_info.name)) + }, [data, selectedTokens]) + + // Build Sankey data structure + // Structure: Source Chain (left) -> Token -> Destination Chain (right) + // To avoid circular links, we create separate source/destination chain nodes + const sankeyData = useMemo((): SankeyData => { + if (!filteredData.length) { + return { nodes: [], links: [] } + } + + const nodesMap = new Map() + const linksMap = new Map() + + filteredData.forEach(row => { + // Create separate nodes for source and destination to avoid cycles + // Source chains are on the left, destination chains on the right + const sourceNode = `from:${row.from_chain}` + const tokenNode = `token:${row.token_info.name}` + const targetNode = `to:${row.destination_chain}` + + // Add source chain node + if (!nodesMap.has(sourceNode)) { + nodesMap.set(sourceNode, { + id: sourceNode, + nodeColor: CHAIN_COLORS[row.from_chain] || '#888888', + }) + } + // Add token node + if (!nodesMap.has(tokenNode)) { + const tokenColor = + tokensList.find(t => t.ticker === row.token_info.name)?.color || '#888888' + nodesMap.set(tokenNode, { id: tokenNode, nodeColor: tokenColor }) + } + // Add destination chain node + if (!nodesMap.has(targetNode)) { + nodesMap.set(targetNode, { + id: targetNode, + nodeColor: CHAIN_COLORS[row.destination_chain] || '#888888', + }) + } + + const value = valueType === 'usd' ? row.total_volume_usd : row.total_count + + // Link: source chain -> token + const link1Key = `${sourceNode}|${tokenNode}` + linksMap.set(link1Key, (linksMap.get(link1Key) || 0) + value) + + // Link: token -> destination chain + const link2Key = `${tokenNode}|${targetNode}` + linksMap.set(link2Key, (linksMap.get(link2Key) || 0) + value) + }) + + // Build links (filter out zero values) + const links: SankeyLink[] = Array.from(linksMap.entries()) + .filter(([, value]) => value > 0) + .map(([key, value]) => { + const [source, target] = key.split('|') + return { source, target, value } + }) + + return { nodes: Array.from(nodesMap.values()), links } + }, [filteredData, valueType, tokensList]) + + const handleValueTypeChange = ( + _: React.MouseEvent, + newValue: ValueType | null, + ) => { + if (newValue) { + setValueType(newValue) + } + } + + // Check if a node should be highlighted based on legend hover + const isNodeHighlighted = (nodeId: string): boolean => { + if (!highlightedItem) return true + return isNodeHighlightMatch(nodeId, highlightedItem) + } + + // Check if a link should be highlighted based on legend hover + const isLinkHighlighted = (source: string, target: string): boolean => { + if (!highlightedItem) return true + return isLinkHighlightMatch(source, target, highlightedItem) + } + + // Get dynamic opacity for nodes + const getNodeOpacity = (nodeId: string): number => { + if (!highlightedItem) return 1 + return isNodeHighlighted(nodeId) ? 1 : 0.15 + } + + // Get dynamic opacity for links + const getLinkOpacity = (source: string, target: string): number => { + if (!highlightedItem) return 0.7 + return isLinkHighlighted(source, target) ? 0.85 : 0.08 + } + + const hasData = sankeyData.nodes.length > 0 && sankeyData.links.length > 0 + + // Modify sankey data to include opacity based on highlight + const styledSankeyData = useMemo((): SankeyData => { + if (!hasData) return sankeyData + + return { + nodes: sankeyData.nodes.map(node => ({ + ...node, + nodeColor: + highlightedItem && !isNodeHighlighted(node.id) + ? alpha(node.nodeColor, 0.15) + : node.nodeColor, + })), + links: sankeyData.links, + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sankeyData, highlightedItem]) + + return ( + + + USD Volume + Tx Count + + } + /> + + + {isLoading ? ( + + ) : !hasData ? ( + + No route data available + + Try adjusting the time period or token filters + + + ) : ( + (node as unknown as SankeyNode).nodeColor || '#888'} + nodeOpacity={1} + nodeHoverOpacity={1} + nodeHoverOthersOpacity={0.2} + nodeThickness={20} + nodeSpacing={28} + nodeBorderWidth={0} + nodeBorderRadius={4} + linkOpacity={highlightedItem ? 0.08 : 0.7} + linkHoverOpacity={0.9} + linkHoverOthersOpacity={0.1} + linkContract={3} + linkBlendMode="normal" + enableLinkGradient + labelPosition="outside" + labelOrientation="horizontal" + labelPadding={16} + label={node => getDisplayLabel(node.id as string)} + labelTextColor={theme.palette.text.primary} + nodeTooltip={({ node }) => ( + + + {getDisplayLabel(node.id as string)} + + + {valueType === 'usd' + ? `$${fNumber(node.value)}` + : `${fNumber(node.value)} transactions`} + + + )} + linkTooltip={({ link }) => ( + + + {getDisplayLabel(link.source.id as string)} →{' '} + {getDisplayLabel(link.target.id as string)} + + + {valueType === 'usd' + ? `$${fNumber(link.value)}` + : `${fNumber(link.value)} transactions`} + + + )} + /> + )} + + + {/* Legend with hover interaction */} + {hasData && ( + + + {/* Chains label */} + + Chains: + + {/* Chain legend */} + {Object.entries(CHAIN_COLORS).map(([chain, color]) => ( + setHighlightedItem(`chain:${chain}`)} + onMouseLeave={() => setHighlightedItem(null)} + sx={{ + cursor: 'pointer', + px: 1.5, + py: 0.5, + borderRadius: 1, + transition: 'all 0.2s ease', + backgroundColor: + highlightedItem === `chain:${chain}` + ? alpha(color, 0.15) + : 'transparent', + '&:hover': { + backgroundColor: alpha(color, 0.15), + }, + }} + > + + + {chain} + + + ))} + + {/* Divider */} + + + {/* Tokens label */} + + Tokens: + + {/* Token legend */} + {tokensList + .filter( + token => + selectedTokens.includes('All') || + selectedTokens.includes(token.ticker), + ) + .map(token => ( + setHighlightedItem(`token:${token.ticker}`)} + onMouseLeave={() => setHighlightedItem(null)} + sx={{ + cursor: 'pointer', + px: 1.5, + py: 0.5, + borderRadius: 1, + transition: 'all 0.2s ease', + backgroundColor: + highlightedItem === `token:${token.ticker}` + ? alpha(token.color, 0.15) + : 'transparent', + '&:hover': { + backgroundColor: alpha(token.color, 0.15), + }, + }} + > + + + {token.ticker} + + + ))} + + + )} + + ) +} diff --git a/src/pages/api/routes.ts b/src/pages/api/routes.ts new file mode 100644 index 0000000..4195fde --- /dev/null +++ b/src/pages/api/routes.ts @@ -0,0 +1,110 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { sendError, sendReply } from './utils' +import db from './database' +import { getNetworkConfig } from 'src/config/helper' +import { getPrices } from './prices' +import dayjs from 'dayjs' + +// Helper to calculate start date based on time period +const calculateStartDate = (timePeriod: string) => { + switch (timePeriod) { + case 'Last 24h': + return dayjs().subtract(1, 'day') + case 'Last Week': + return dayjs().subtract(7, 'day') + case 'Last Month': + return dayjs().subtract(30, 'day') + case 'Last 6 months': + return dayjs().subtract(6, 'month') + case 'Last year': + return dayjs().subtract(365, 'day') + case 'All time': + return dayjs().subtract(1000, 'day') + default: + return dayjs().subtract(30, 'day') + } +} + +// Helper to get network name from ID +const getNetworkName = (id: number, networkId: { SUI: number; ETH: number }): string | null => { + for (const [key, value] of Object.entries(networkId)) { + if (value === id) { + return key + } + } + return null +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const networkConfig = getNetworkConfig({ req }) + const timePeriod = (req.query.timePeriod as string) || 'Last Month' + + const startDate = calculateStartDate(timePeriod) + const startTimestampMs = startDate.valueOf() + + /** + * Query to get route data grouped by source chain, destination chain, and token + * This gives us the flow of assets between chains + */ + const query = await db[networkConfig.network]` + SELECT + chain_id, + destination_chain, + token_id, + COUNT(*) AS total_count, + SUM(amount) AS total_volume + FROM + public.token_transfer_data + WHERE + is_finalized = true + AND timestamp_ms >= ${startTimestampMs} + GROUP BY + chain_id, + destination_chain, + token_id + ORDER BY + total_volume DESC` + + const prices = await getPrices(networkConfig.network) + + // Transform the data with proper chain names and token info + const transformedData = (query as any[]) + .map(row => { + const tokenData = networkConfig.config.coins[row.token_id] + const priceData = prices.find(price => price.token_id === row.token_id) + + const fromChain = getNetworkName(row.chain_id, networkConfig.config.networkId) + const destinationChain = getNetworkName( + row.destination_chain, + networkConfig.config.networkId, + ) + + if (!tokenData || !fromChain || !destinationChain) { + return null + } + + const totalVolume = Number(row.total_volume) / tokenData.deno + const totalVolumeUsd = totalVolume * Number(priceData?.price || 0) + + return { + from_chain: fromChain, + destination_chain: destinationChain, + token_id: row.token_id, + token_info: { + id: tokenData.id, + name: tokenData.name, + deno: tokenData.deno, + }, + total_count: Number(row.total_count), + total_volume: totalVolume, + total_volume_usd: totalVolumeUsd, + } + }) + .filter(Boolean) + + sendReply(res, transformedData) + } catch (error) { + sendError(res, error) + } +} diff --git a/src/utils/axios.ts b/src/utils/axios.ts index 6954bf5..6107a18 100644 --- a/src/utils/axios.ts +++ b/src/utils/axios.ts @@ -40,6 +40,7 @@ export const endpoints = { outflows: '/api/outflows', bridgeMetrics: '/api/bridge-metrics', // Add this line fees: '/api/fees', + routes: '/api/routes', volume: { daily: '/api/volume/daily', hourly: '/api/volume/hourly', diff --git a/src/utils/types.ts b/src/utils/types.ts index a9ea867..a69968f 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -226,3 +226,18 @@ export type BridgeMetricsResponse = { } // Keep number for backward compatibility } + +// Route map data for Sankey chart +export type RouteMapRow = { + from_chain: string + destination_chain: string + token_id: number + token_info: { + id: number + name: string + deno: number + } + total_count: number + total_volume: number + total_volume_usd: number +} diff --git a/yarn.lock b/yarn.lock index bbbd627..71ebea7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1909,6 +1909,96 @@ resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.29.tgz#9097b85893a51ca9ba3b9d1733a4aab954edeab5" integrity sha512-iPPwUEKnVs7pwR0EBLJlwxLD7TTHWS/AoVZx1l9ZQzfQciqaFEr5AlYzA2uB6Fyby1IF18t4PL0nTpB+k4Tzlw== +"@nivo/colors@0.99.0": + version "0.99.0" + resolved "https://registry.yarnpkg.com/@nivo/colors/-/colors-0.99.0.tgz#f844a0f0de0597b35829405541e145f6db50c830" + integrity sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w== + dependencies: + "@nivo/core" "0.99.0" + "@nivo/theming" "0.99.0" + "@types/d3-color" "^3.0.0" + "@types/d3-scale" "^4.0.8" + "@types/d3-scale-chromatic" "^3.0.0" + d3-color "^3.1.0" + d3-scale "^4.0.2" + d3-scale-chromatic "^3.0.0" + lodash "^4.17.21" + +"@nivo/core@0.99.0", "@nivo/core@^0.99.0": + version "0.99.0" + resolved "https://registry.yarnpkg.com/@nivo/core/-/core-0.99.0.tgz#91ccf3d2419fcfb5f740dba468f0d6f059933af4" + integrity sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g== + dependencies: + "@nivo/theming" "0.99.0" + "@nivo/tooltip" "0.99.0" + "@react-spring/web" "9.4.5 || ^9.7.2 || ^10.0" + "@types/d3-shape" "^3.1.6" + d3-color "^3.1.0" + d3-format "^1.4.4" + d3-interpolate "^3.0.1" + d3-scale "^4.0.2" + d3-scale-chromatic "^3.0.0" + d3-shape "^3.2.0" + d3-time-format "^3.0.0" + lodash "^4.17.21" + react-virtualized-auto-sizer "^1.0.26" + use-debounce "^10.0.4" + +"@nivo/legends@0.99.0": + version "0.99.0" + resolved "https://registry.yarnpkg.com/@nivo/legends/-/legends-0.99.0.tgz#4f1fede8c450dad942b851a9a429838e343aea1b" + integrity sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA== + dependencies: + "@nivo/colors" "0.99.0" + "@nivo/core" "0.99.0" + "@nivo/text" "0.99.0" + "@nivo/theming" "0.99.0" + "@types/d3-scale" "^4.0.8" + d3-scale "^4.0.2" + +"@nivo/sankey@^0.99.0": + version "0.99.0" + resolved "https://registry.yarnpkg.com/@nivo/sankey/-/sankey-0.99.0.tgz#58aa360a7bb37cf950b9e4fb2d052eb6bd302bf3" + integrity sha512-u5hySywsachjo9cHdUxCR9qwD6gfRVPEAcpuIUKiA0WClDjdGbl3vkrQcQcFexJUBThqSSbwGCDWR+2INXSbTw== + dependencies: + "@nivo/colors" "0.99.0" + "@nivo/core" "0.99.0" + "@nivo/legends" "0.99.0" + "@nivo/text" "0.99.0" + "@nivo/theming" "0.99.0" + "@nivo/tooltip" "0.99.0" + "@react-spring/web" "9.4.5 || ^9.7.2 || ^10.0" + "@types/d3-sankey" "^0.11.2" + "@types/d3-shape" "^3.1.6" + d3-sankey "^0.12.3" + d3-shape "^3.2.0" + lodash "^4.17.21" + +"@nivo/text@0.99.0": + version "0.99.0" + resolved "https://registry.yarnpkg.com/@nivo/text/-/text-0.99.0.tgz#b52f37d903e731f60027c814658e271676fafdf8" + integrity sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ== + dependencies: + "@nivo/core" "0.99.0" + "@nivo/theming" "0.99.0" + "@react-spring/web" "9.4.5 || ^9.7.2 || ^10.0" + +"@nivo/theming@0.99.0": + version "0.99.0" + resolved "https://registry.yarnpkg.com/@nivo/theming/-/theming-0.99.0.tgz#89de03832081153093dcfc2eb2fdaaf3424da963" + integrity sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg== + dependencies: + lodash "^4.17.21" + +"@nivo/tooltip@0.99.0": + version "0.99.0" + resolved "https://registry.yarnpkg.com/@nivo/tooltip/-/tooltip-0.99.0.tgz#63a1bc3b428cb2a07a7f763ad8547e39dd4bcf13" + integrity sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg== + dependencies: + "@nivo/core" "0.99.0" + "@nivo/theming" "0.99.0" + "@react-spring/web" "9.4.5 || ^9.7.2 || ^10.0" + "@noble/ciphers@1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-1.2.1.tgz#3812b72c057a28b44ff0ad4aff5ca846e5b9cdc9" @@ -2273,6 +2363,51 @@ react-remove-scroll "2.6.2" ua-parser-js "^1.0.37" +"@react-spring/animated@~10.0.3": + version "10.0.3" + resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-10.0.3.tgz#b42f7041a51d38f395e9ba5fb53ca68c34cd324f" + integrity sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ== + dependencies: + "@react-spring/shared" "~10.0.3" + "@react-spring/types" "~10.0.3" + +"@react-spring/core@~10.0.3": + version "10.0.3" + resolved "https://registry.yarnpkg.com/@react-spring/core/-/core-10.0.3.tgz#3b4f3991f5902ce46770c2c1ef05c8e53c3a0f73" + integrity sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ== + dependencies: + "@react-spring/animated" "~10.0.3" + "@react-spring/shared" "~10.0.3" + "@react-spring/types" "~10.0.3" + +"@react-spring/rafz@~10.0.3": + version "10.0.3" + resolved "https://registry.yarnpkg.com/@react-spring/rafz/-/rafz-10.0.3.tgz#9b328c3992b23d6317452998670636d6b783f2c4" + integrity sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg== + +"@react-spring/shared@~10.0.3": + version "10.0.3" + resolved "https://registry.yarnpkg.com/@react-spring/shared/-/shared-10.0.3.tgz#654d03c74d3277bae1a565aff981979536be6002" + integrity sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q== + dependencies: + "@react-spring/rafz" "~10.0.3" + "@react-spring/types" "~10.0.3" + +"@react-spring/types@~10.0.3": + version "10.0.3" + resolved "https://registry.yarnpkg.com/@react-spring/types/-/types-10.0.3.tgz#0c2d7a7e783a6f652bcd24cac80ed569bc2ad8d9" + integrity sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ== + +"@react-spring/web@9.4.5 || ^9.7.2 || ^10.0": + version "10.0.3" + resolved "https://registry.yarnpkg.com/@react-spring/web/-/web-10.0.3.tgz#ae3a9ea2362b1d70d2ec36a1e2747c6cee2540a9" + integrity sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A== + dependencies: + "@react-spring/animated" "~10.0.3" + "@react-spring/core" "~10.0.3" + "@react-spring/shared" "~10.0.3" + "@react-spring/types" "~10.0.3" + "@reown/appkit-common@1.7.8": version "1.7.8" resolved "https://registry.yarnpkg.com/@reown/appkit-common/-/appkit-common-1.7.8.tgz#6fc29db977b7325e8170b1fd08176fe15ea0b39c" @@ -2633,6 +2768,59 @@ resolved "https://registry.npmjs.org/@types/autosuggest-highlight/-/autosuggest-highlight-3.2.3.tgz" integrity sha512-8Mb21KWtpn6PvRQXjsKhrXIcxbSloGqNH50RntwGeJsGPW4xvNhfml+3kKulaKpO/7pgZfOmzsJz7VbepArlGQ== +"@types/d3-color@^3.0.0": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2" + integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A== + +"@types/d3-path@*": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.1.tgz#f632b380c3aca1dba8e34aa049bcd6a4af23df8a" + integrity sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg== + +"@types/d3-path@^1": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-1.0.11.tgz#45420fee2d93387083b34eae4fe6d996edf482bc" + integrity sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw== + +"@types/d3-sankey@^0.11.2": + version "0.11.2" + resolved "https://registry.yarnpkg.com/@types/d3-sankey/-/d3-sankey-0.11.2.tgz#803214b11dc0a17db5d782fe9055cd92b06a5d75" + integrity sha512-U6SrTWUERSlOhnpSrgvMX64WblX1AxX6nEjI2t3mLK2USpQrnbwYYK+AS9SwiE7wgYmOsSSKoSdr8aoKBH0HgQ== + dependencies: + "@types/d3-shape" "^1" + +"@types/d3-scale-chromatic@^3.0.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz#dc6d4f9a98376f18ea50bad6c39537f1b5463c39" + integrity sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ== + +"@types/d3-scale@^4.0.8": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.9.tgz#57a2f707242e6fe1de81ad7bfcccaaf606179afb" + integrity sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw== + dependencies: + "@types/d3-time" "*" + +"@types/d3-shape@^1": + version "1.3.12" + resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-1.3.12.tgz#8f2f9f7a12e631ce6700d6d55b84795ce2c8b259" + integrity sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q== + dependencies: + "@types/d3-path" "^1" + +"@types/d3-shape@^3.1.6": + version "3.1.8" + resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.8.tgz#d1516cc508753be06852cd06758e3bb54a22b0e3" + integrity sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w== + dependencies: + "@types/d3-path" "*" + +"@types/d3-time@*": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.4.tgz#8472feecd639691450dd8000eb33edd444e1323f" + integrity sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g== + "@types/debug@^4.1.7": version "4.1.12" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" @@ -3998,6 +4186,121 @@ cuer@0.0.2: dependencies: qr "~0" +"d3-array@1 - 2", d3-array@2: + version "2.12.1" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-2.12.1.tgz#e20b41aafcdffdf5d50928004ececf815a465e81" + integrity sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ== + dependencies: + internmap "^1.0.0" + +"d3-array@2 - 3", "d3-array@2.10.0 - 3": + version "3.2.4" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5" + integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg== + dependencies: + internmap "1 - 2" + +"d3-color@1 - 3", d3-color@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" + integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== + +"d3-format@1 - 3": + version "3.1.2" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.2.tgz#01fdb46b58beb1f55b10b42ad70b6e344d5eb2ae" + integrity sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg== + +d3-format@^1.4.4: + version "1.4.5" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4" + integrity sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ== + +"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" + integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== + dependencies: + d3-color "1 - 3" + +d3-path@1: + version "1.0.9" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf" + integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg== + +d3-path@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526" + integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ== + +d3-sankey@^0.12.3: + version "0.12.3" + resolved "https://registry.yarnpkg.com/d3-sankey/-/d3-sankey-0.12.3.tgz#b3c268627bd72e5d80336e8de6acbfec9d15d01d" + integrity sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ== + dependencies: + d3-array "1 - 2" + d3-shape "^1.2.0" + +d3-scale-chromatic@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz#34c39da298b23c20e02f1a4b239bd0f22e7f1314" + integrity sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ== + dependencies: + d3-color "1 - 3" + d3-interpolate "1 - 3" + +d3-scale@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" + integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ== + dependencies: + d3-array "2.10.0 - 3" + d3-format "1 - 3" + d3-interpolate "1.2.0 - 3" + d3-time "2.1.1 - 3" + d3-time-format "2 - 4" + +d3-shape@^1.2.0: + version "1.3.7" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7" + integrity sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw== + dependencies: + d3-path "1" + +d3-shape@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5" + integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA== + dependencies: + d3-path "^3.1.0" + +"d3-time-format@2 - 4": + version "4.1.0" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" + integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg== + dependencies: + d3-time "1 - 3" + +d3-time-format@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-3.0.0.tgz#df8056c83659e01f20ac5da5fdeae7c08d5f1bb6" + integrity sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag== + dependencies: + d3-time "1 - 2" + +"d3-time@1 - 2": + version "2.1.1" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-2.1.1.tgz#e9d8a8a88691f4548e68ca085e5ff956724a6682" + integrity sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ== + dependencies: + d3-array "2" + +"d3-time@1 - 3", "d3-time@2.1.1 - 3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7" + integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q== + dependencies: + d3-array "2 - 3" + damerau-levenshtein@^1.0.8: version "1.0.8" resolved "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz" @@ -5342,6 +5645,16 @@ internal-slot@^1.0.4, internal-slot@^1.0.7: hasown "^2.0.0" side-channel "^1.0.4" +"internmap@1 - 2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" + integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== + +internmap@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95" + integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw== + iron-webcrypto@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz#aa60ff2aa10550630f4c0b11fd2442becdb35a6f" @@ -6689,6 +7002,11 @@ react-transition-group@^4.4.5: loose-envify "^1.4.0" prop-types "^15.6.2" +react-virtualized-auto-sizer@^1.0.26: + version "1.0.26" + resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz#e9470ef6a778dc4f1d5fd76305fa2d8b610c357a" + integrity sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A== + react@^18.3.1: version "18.3.1" resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz" From a6988f03471fec3918684fd95db0a43c188491f7 Mon Sep 17 00:00:00 2001 From: novi Date: Tue, 20 Jan 2026 20:43:37 +0200 Subject: [PATCH 2/3] Update bridge-route-map.tsx --- src/components/chart/bridge-route-map.tsx | 27 ++++++++++++++--------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/components/chart/bridge-route-map.tsx b/src/components/chart/bridge-route-map.tsx index 5797332..381e71b 100644 --- a/src/components/chart/bridge-route-map.tsx +++ b/src/components/chart/bridge-route-map.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useMemo } from 'react' +import { useState, useMemo, useCallback } from 'react' import dynamic from 'next/dynamic' import { Card, @@ -170,16 +170,22 @@ export default function BridgeRouteMap() { } // Check if a node should be highlighted based on legend hover - const isNodeHighlighted = (nodeId: string): boolean => { - if (!highlightedItem) return true - return isNodeHighlightMatch(nodeId, highlightedItem) - } + const isNodeHighlighted = useCallback( + (nodeId: string): boolean => { + if (!highlightedItem) return true + return isNodeHighlightMatch(nodeId, highlightedItem) + }, + [highlightedItem], + ) // Check if a link should be highlighted based on legend hover - const isLinkHighlighted = (source: string, target: string): boolean => { - if (!highlightedItem) return true - return isLinkHighlightMatch(source, target, highlightedItem) - } + const isLinkHighlighted = useCallback( + (source: string, target: string): boolean => { + if (!highlightedItem) return true + return isLinkHighlightMatch(source, target, highlightedItem) + }, + [highlightedItem], + ) // Get dynamic opacity for nodes const getNodeOpacity = (nodeId: string): number => { @@ -209,8 +215,7 @@ export default function BridgeRouteMap() { })), links: sankeyData.links, } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sankeyData, highlightedItem]) + }, [sankeyData, highlightedItem, hasData, isNodeHighlighted]) return ( From 8b9e0137f5260f82b22ed9ee59c95c0f2747d818 Mon Sep 17 00:00:00 2001 From: novi Date: Tue, 20 Jan 2026 21:04:08 +0200 Subject: [PATCH 3/3] make improvements --- src/components/chart/bridge-route-map.tsx | 147 ++++++++++++++-------- src/pages/api/cards.ts | 9 +- src/pages/api/routes.ts | 22 +--- 3 files changed, 104 insertions(+), 74 deletions(-) diff --git a/src/components/chart/bridge-route-map.tsx b/src/components/chart/bridge-route-map.tsx index 381e71b..12a9622 100644 --- a/src/components/chart/bridge-route-map.tsx +++ b/src/components/chart/bridge-route-map.tsx @@ -41,25 +41,6 @@ const getDisplayLabel = (id: string): string => { return id } -// Helper to check if a node matches the highlighted item -const isNodeHighlightMatch = (nodeId: string, highlighted: string): boolean => { - if (highlighted.startsWith('chain:')) { - // Highlighting a chain - match both from: and to: nodes for that chain - const chainName = highlighted.slice(6) - return nodeId === `from:${chainName}` || nodeId === `to:${chainName}` - } - if (highlighted.startsWith('token:')) { - // Highlighting a token - match the token node - return nodeId === highlighted - } - return false -} - -// Helper to check if a link matches the highlighted item -const isLinkHighlightMatch = (source: string, target: string, highlighted: string): boolean => { - return isNodeHighlightMatch(source, highlighted) || isNodeHighlightMatch(target, highlighted) -} - interface SankeyNode { id: string nodeColor: string @@ -69,6 +50,7 @@ interface SankeyLink { source: string target: string value: number + color?: string } interface SankeyData { @@ -169,53 +151,120 @@ export default function BridgeRouteMap() { } } + // Compute which nodes and links should be highlighted based on legend hover + // For chains: highlight routes ORIGINATING FROM that chain (outgoing flows) + // For tokens: highlight only the token and its direct connections + const highlightedElements = useMemo(() => { + if (!highlightedItem || !sankeyData.links.length) { + return { nodes: new Set(), links: new Set() } + } + + const highlightedNodes = new Set() + const highlightedLinks = new Set() + + if (highlightedItem.startsWith('chain:')) { + // Highlighting a chain - show all routes ORIGINATING FROM this chain + const chainName = highlightedItem.slice(6) + const fromNode = `from:${chainName}` + + // Find first leg: from:CHAIN -> token:TOKEN + sankeyData.links.forEach(link => { + if (link.source === fromNode) { + highlightedLinks.add(`${link.source}|${link.target}`) + highlightedNodes.add(link.source) + highlightedNodes.add(link.target) + + // Find second leg: token:TOKEN -> to:OTHER_CHAIN + const tokenNode = link.target + sankeyData.links.forEach(link2 => { + if (link2.source === tokenNode && link2.target.startsWith('to:')) { + highlightedLinks.add(`${link2.source}|${link2.target}`) + highlightedNodes.add(link2.target) + } + }) + } + }) + } else if (highlightedItem.startsWith('token:')) { + // Highlighting a token - show only connections to/from this token + const tokenNode = highlightedItem + + sankeyData.links.forEach(link => { + if (link.source === tokenNode || link.target === tokenNode) { + highlightedLinks.add(`${link.source}|${link.target}`) + highlightedNodes.add(link.source) + highlightedNodes.add(link.target) + } + }) + } + + return { nodes: highlightedNodes, links: highlightedLinks } + }, [highlightedItem, sankeyData.links]) + // Check if a node should be highlighted based on legend hover const isNodeHighlighted = useCallback( (nodeId: string): boolean => { if (!highlightedItem) return true - return isNodeHighlightMatch(nodeId, highlightedItem) + return highlightedElements.nodes.has(nodeId) }, - [highlightedItem], + [highlightedItem, highlightedElements.nodes], ) // Check if a link should be highlighted based on legend hover const isLinkHighlighted = useCallback( (source: string, target: string): boolean => { if (!highlightedItem) return true - return isLinkHighlightMatch(source, target, highlightedItem) + return highlightedElements.links.has(`${source}|${target}`) }, - [highlightedItem], + [highlightedItem, highlightedElements.links], ) - // Get dynamic opacity for nodes - const getNodeOpacity = (nodeId: string): number => { - if (!highlightedItem) return 1 - return isNodeHighlighted(nodeId) ? 1 : 0.15 - } - - // Get dynamic opacity for links - const getLinkOpacity = (source: string, target: string): number => { - if (!highlightedItem) return 0.7 - return isLinkHighlighted(source, target) ? 0.85 : 0.08 - } - const hasData = sankeyData.nodes.length > 0 && sankeyData.links.length > 0 + // Create a map of node colors for link styling + const nodeColorMap = useMemo(() => { + const map = new Map() + sankeyData.nodes.forEach(node => { + map.set(node.id, node.nodeColor) + }) + return map + }, [sankeyData.nodes]) + // Modify sankey data to include opacity based on highlight const styledSankeyData = useMemo((): SankeyData => { if (!hasData) return sankeyData - return { - nodes: sankeyData.nodes.map(node => ({ - ...node, - nodeColor: - highlightedItem && !isNodeHighlighted(node.id) - ? alpha(node.nodeColor, 0.15) - : node.nodeColor, - })), - links: sankeyData.links, - } - }, [sankeyData, highlightedItem, hasData, isNodeHighlighted]) + // When highlighting, we fade non-related nodes + const styledNodes = sankeyData.nodes.map(node => ({ + ...node, + nodeColor: + highlightedItem && !isNodeHighlighted(node.id) + ? alpha(node.nodeColor, 0.2) + : node.nodeColor, + })) + + // For links - we need to set colors that will be picked up + // Nivo will use these for the gradient + const styledLinks = sankeyData.links.map(link => { + const linkIsHighlighted = isLinkHighlighted(link.source, link.target) + + if (highlightedItem && !linkIsHighlighted) { + // Non-highlighted links get faded + const sourceColor = nodeColorMap.get(link.source) || '#888888' + return { + ...link, + // Store a marker that this link should be faded + _faded: true, + color: alpha(sourceColor, 0.08), + } + } + return { + ...link, + _faded: false, + } + }) + + return { nodes: styledNodes, links: styledLinks } + }, [sankeyData, highlightedItem, hasData, isNodeHighlighted, isLinkHighlighted, nodeColorMap]) return ( @@ -289,9 +338,9 @@ export default function BridgeRouteMap() { nodeSpacing={28} nodeBorderWidth={0} nodeBorderRadius={4} - linkOpacity={highlightedItem ? 0.08 : 0.7} - linkHoverOpacity={0.9} - linkHoverOthersOpacity={0.1} + linkOpacity={0.75} + linkHoverOpacity={1} + linkHoverOthersOpacity={0.12} linkContract={3} linkBlendMode="normal" enableLinkGradient diff --git a/src/pages/api/cards.ts b/src/pages/api/cards.ts index 0f3067d..c75472c 100644 --- a/src/pages/api/cards.ts +++ b/src/pages/api/cards.ts @@ -11,8 +11,9 @@ import { getPrices } from './prices' dayjs.extend(isoWeek) dayjs.extend(weekOfYear) -export const calculateStartDate = (timePeriod: string, currentDate?: dayjs.Dayjs) => { - let day = currentDate || dayjs() +// Returns timestamp in ms - used for interval calculations with optional base date +const calculateStartTimestamp = (timePeriod: string, currentDate?: dayjs.Dayjs): number => { + const day = currentDate || dayjs() switch (timePeriod) { case 'Last 24h': return day.subtract(1, 'day').valueOf() @@ -32,10 +33,10 @@ export const calculateStartDate = (timePeriod: string, currentDate?: dayjs.Dayjs } export const computerIntervals = (timePeriod: TimePeriod, computePrevious?: boolean) => { - const startDate = calculateStartDate(timePeriod) + const startDate = calculateStartTimestamp(timePeriod) const fromInterval = computePrevious - ? calculateStartDate(timePeriod, dayjs(startDate)) + ? calculateStartTimestamp(timePeriod, dayjs(startDate)) : startDate const toInterval = computePrevious ? startDate : new Date().getTime() diff --git a/src/pages/api/routes.ts b/src/pages/api/routes.ts index 4195fde..108eb9a 100644 --- a/src/pages/api/routes.ts +++ b/src/pages/api/routes.ts @@ -3,27 +3,7 @@ import { sendError, sendReply } from './utils' import db from './database' import { getNetworkConfig } from 'src/config/helper' import { getPrices } from './prices' -import dayjs from 'dayjs' - -// Helper to calculate start date based on time period -const calculateStartDate = (timePeriod: string) => { - switch (timePeriod) { - case 'Last 24h': - return dayjs().subtract(1, 'day') - case 'Last Week': - return dayjs().subtract(7, 'day') - case 'Last Month': - return dayjs().subtract(30, 'day') - case 'Last 6 months': - return dayjs().subtract(6, 'month') - case 'Last year': - return dayjs().subtract(365, 'day') - case 'All time': - return dayjs().subtract(1000, 'day') - default: - return dayjs().subtract(30, 'day') - } -} +import { calculateStartDate } from 'src/utils/format-chart-data' // Helper to get network name from ID const getNetworkName = (id: number, networkId: { SUI: number; ETH: number }): string | null => {