1- import React , { useState , useCallback , useEffect } from 'react'
1+ import React , { useState , useCallback , useEffect , useMemo } from 'react'
22import { GraphExplorer , GraphExplorerSigma } from '@trustin/txgraph'
33import type { TxNode , TxGraph } from '@trustin/txgraph'
4+ import { SUPPORTED_CHAINS , type ChainName } from '@trustin/txgraph-core'
45import { exploreGraph , expandNode } from './api'
56import type { DataSourceType } from './api'
67
78type Renderer = 'reactflow' | 'sigma'
8- type Chain = 'Ethereum' | 'Tron'
99
10- const SAMPLE_ADDRESSES = {
10+ const SAMPLE_ADDRESSES : Partial < Record < ChainName , string > > = {
1111 Ethereum : '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' ,
1212 Tron : 'TN3W4H6rK2ce4vX9YnFQHwKENnHjoxb3m9' ,
13+ BSC : '0x8894e0a0c962cb723c1ef8f1d0de620172a38596' ,
14+ Polygon : '0x8894e0a0c962cb723c1ef8f1d0de620172a38596' ,
15+ Arbitrum : '0x8894e0a0c962cb723c1ef8f1d0de620172a38596' ,
1316}
1417
15- function detectChain ( addr : string ) : Chain | null {
18+ function detectChain ( addr : string ) : ChainName | null {
1619 const trimmed = addr . trim ( )
1720 if ( / ^ 0 x [ 0 - 9 a - f A - F ] { 40 } $ / . test ( trimmed ) ) return 'Ethereum'
1821 if ( / ^ T [ 1 - 9 A - H J - N P - Z a - k m - z ] { 33 } $ / . test ( trimmed ) ) return 'Tron'
@@ -66,7 +69,7 @@ export default function App() {
6669 const qp = useQueryParams ( )
6770 const [ isDark , setIsDark ] = useState ( true )
6871 const [ address , setAddress ] = useState ( qp . address )
69- const [ chain , setChain ] = useState < Chain > ( qp . chain || ( detectChain ( qp . address ) ?? 'Ethereum' ) )
72+ const [ chain , setChain ] = useState < ChainName > ( qp . chain as ChainName || ( detectChain ( qp . address ) ?? 'Ethereum' ) )
7073 const [ direction , setDirection ] = useState < 'in' | 'out' | 'all' > ( qp . direction || 'out' )
7174 const [ token , setToken ] = useState ( qp . token )
7275 const [ fromDate , setFromDate ] = useState ( qp . fromDate )
@@ -78,6 +81,8 @@ export default function App() {
7881 const [ expandingNode , setExpandingNode ] = useState < string | null > ( null )
7982 const [ selectedAddress , setSelectedAddress ] = useState < string | null > ( null )
8083 const [ error , setError ] = useState < string | null > ( null )
84+ const [ searchQuery , setSearchQuery ] = useState ( '' )
85+ const [ annotations , setAnnotations ] = useState < Record < string , string > > ( { } )
8186
8287 useEffect ( ( ) => {
8388 if ( isDark ) {
@@ -89,6 +94,76 @@ export default function App() {
8994
9095 const c = themeColors ( isDark )
9196
97+ const exportJSON = useCallback ( ( ) => {
98+ if ( ! graph ) return
99+ const blob = new Blob ( [ JSON . stringify ( graph , null , 2 ) ] , { type : 'application/json' } )
100+ const url = URL . createObjectURL ( blob )
101+ const a = document . createElement ( 'a' )
102+ a . href = url
103+ a . download = 'txgraph.json'
104+ a . click ( )
105+ URL . revokeObjectURL ( url )
106+ } , [ graph ] )
107+
108+ const exportCSV = useCallback ( ( ) => {
109+ if ( ! graph ) return
110+ const header = 'from,to,direction,amount,formatted_amount,token,tx_count,last_timestamp\n'
111+ const rows = graph . edges . map ( e =>
112+ [ e . from , e . to , e . direction , e . amount , `"${ e . formatted_amount } "` , e . token || '' , e . tx_count ?? '' , e . last_timestamp ] . join ( ',' )
113+ ) . join ( '\n' )
114+ const blob = new Blob ( [ header + rows ] , { type : 'text/csv' } )
115+ const url = URL . createObjectURL ( blob )
116+ const a = document . createElement ( 'a' )
117+ a . href = url
118+ a . download = 'txgraph.csv'
119+ a . click ( )
120+ URL . revokeObjectURL ( url )
121+ } , [ graph ] )
122+
123+ const exportPNG = useCallback ( ( ) => {
124+ // Capture the graph container as PNG via canvas
125+ const container = document . querySelector ( '.react-flow, .sigma-container' ) ?. closest ( '[style]' ) ?. parentElement
126+ if ( ! container ) return
127+ import ( 'html-to-image' ) . then ( ( { toPng } ) => {
128+ toPng ( container as HTMLElement , { backgroundColor : isDark ? '#0f172a' : '#f8fafc' } ) . then ( dataUrl => {
129+ const a = document . createElement ( 'a' )
130+ a . href = dataUrl
131+ a . download = 'txgraph.png'
132+ a . click ( )
133+ } ) . catch ( console . error )
134+ } ) . catch ( console . error )
135+ } , [ isDark ] )
136+
137+ const LARGE_GRAPH_THRESHOLD = 200
138+
139+ // Auto-switch to Sigma for large graphs
140+ useEffect ( ( ) => {
141+ if ( graph && graph . nodes . length > LARGE_GRAPH_THRESHOLD && renderer === 'reactflow' ) {
142+ setRenderer ( 'sigma' )
143+ }
144+ } , [ graph ] )
145+
146+ // Search within graph: find first matching node by address or ENS tag
147+ const handleSearch = useCallback ( ( ) => {
148+ if ( ! graph || ! searchQuery . trim ( ) ) return
149+ const q = searchQuery . trim ( ) . toLowerCase ( )
150+ const match = graph . nodes . find ( n =>
151+ n . address . toLowerCase ( ) . includes ( q ) ||
152+ n . tags . some ( t => ( t . name || '' ) . toLowerCase ( ) . includes ( q ) )
153+ )
154+ if ( match ) setSelectedAddress ( match . address )
155+ } , [ graph , searchQuery ] )
156+
157+ // Extract unique token symbols from graph edges for dynamic filtering
158+ const availableTokens = useMemo ( ( ) => {
159+ if ( ! graph ) return [ ]
160+ const tokens = new Set < string > ( )
161+ for ( const edge of graph . edges ) {
162+ if ( edge . token ) tokens . add ( edge . token )
163+ }
164+ return Array . from ( tokens ) . sort ( )
165+ } , [ graph ] )
166+
92167 const handleExplore = useCallback ( async ( ) => {
93168 // Clear query params after first explore to avoid re-triggering
94169 if ( window . location . search ) {
@@ -193,13 +268,15 @@ export default function App() {
193268 { /* Address input */ }
194269 < input
195270 type = "text"
196- placeholder = "Enter blockchain address…"
271+ placeholder = "Enter address (or multiple separated by comma) …"
197272 value = { address }
198273 onChange = { ( e ) => {
199274 const val = e . target . value
200275 setAddress ( val )
201276 const detected = detectChain ( val )
202- if ( detected ) setChain ( detected )
277+ // Only auto-switch chain for Tron (unambiguous T prefix).
278+ // 0x addresses are valid on all EVM chains, so keep current selection.
279+ if ( detected === 'Tron' ) setChain ( 'Tron' )
203280 } }
204281 onKeyDown = { ( e ) => e . key === 'Enter' && handleExplore ( ) }
205282 style = { {
@@ -214,13 +291,14 @@ export default function App() {
214291 < select
215292 value = { chain }
216293 onChange = { ( e ) => {
217- setChain ( e . target . value as Chain )
294+ setChain ( e . target . value as ChainName )
218295 setAddress ( '' )
219296 } }
220297 style = { inputStyle }
221298 >
222- < option > Ethereum</ option >
223- < option > Tron</ option >
299+ { SUPPORTED_CHAINS . map ( ( c ) => (
300+ < option key = { c } value = { c } > { c } </ option >
301+ ) ) }
224302 </ select >
225303
226304 { /* Data source */ }
@@ -251,8 +329,15 @@ export default function App() {
251329 style = { inputStyle }
252330 >
253331 < option value = "" > All Tokens</ option >
254- < option value = "usdt" > USDT</ option >
255- < option value = "usdc" > USDC</ option >
332+ { availableTokens . length > 0
333+ ? availableTokens . map ( ( t ) => (
334+ < option key = { t } value = { t . toLowerCase ( ) } > { t } </ option >
335+ ) )
336+ : < >
337+ < option value = "usdt" > USDT</ option >
338+ < option value = "usdc" > USDC</ option >
339+ </ >
340+ }
256341 </ select >
257342
258343 { /* Date range */ }
@@ -327,7 +412,7 @@ export default function App() {
327412 < div style = { { fontSize : 48 } } > 🔍</ div >
328413 < div style = { { fontSize : 15 } } > Enter an address and click < strong > Explore</ strong > </ div >
329414 < div style = { { fontSize : 12 , color : c . dimmed } } >
330- { dataSource === 'trustin' ? 'Powered by TrustIn API' : `On-Chain via ${ chain === 'Tron' ? 'Tronscan' : 'Etherscan' } ` }
415+ { dataSource === 'trustin' ? 'Powered by TrustIn API' : `On-Chain via ${ chain === 'Tron' ? 'Tronscan' : chain === 'BSC' ? 'BscScan' : chain === 'Polygon' ? 'PolygonScan' : chain === 'Arbitrum' ? 'Arbiscan' : 'Etherscan' } ` }
331416 </ div >
332417 </ div >
333418 ) }
@@ -379,6 +464,53 @@ export default function App() {
379464 < button onClick = { ( ) => setRenderer ( 'sigma' ) } style = { btnStyle ( renderer === 'sigma' ) } > Sigma</ button >
380465 </ div >
381466 ) }
467+
468+ { /* Search within graph */ }
469+ { graph && (
470+ < div style = { {
471+ position : 'absolute' ,
472+ top : 10 ,
473+ left : '50%' ,
474+ transform : 'translateX(-50%)' ,
475+ display : 'flex' ,
476+ gap : 4 ,
477+ background : c . overlayBg ,
478+ borderRadius : 6 ,
479+ border : `1px solid ${ c . border } ` ,
480+ padding : 2 ,
481+ zIndex : 10 ,
482+ } } >
483+ < input
484+ type = "text"
485+ placeholder = "Search address or ENS…"
486+ value = { searchQuery }
487+ onChange = { ( e ) => setSearchQuery ( e . target . value ) }
488+ onKeyDown = { ( e ) => e . key === 'Enter' && handleSearch ( ) }
489+ style = { { ...inputStyle , fontSize : 12 , minWidth : 180 , padding : '4px 8px' } }
490+ />
491+ < button onClick = { handleSearch } style = { { ...btnStyle ( false ) , padding : '4px 10px' , fontSize : 12 } } > Find</ button >
492+ </ div >
493+ ) }
494+
495+ { /* Export buttons */ }
496+ { graph && (
497+ < div style = { {
498+ position : 'absolute' ,
499+ top : 10 ,
500+ right : 10 ,
501+ display : 'flex' ,
502+ gap : 2 ,
503+ background : c . overlayBg ,
504+ borderRadius : 6 ,
505+ border : `1px solid ${ c . border } ` ,
506+ padding : 2 ,
507+ zIndex : 10 ,
508+ } } >
509+ < button onClick = { exportPNG } style = { btnStyle ( false ) } title = "Export as PNG" > PNG</ button >
510+ < button onClick = { exportJSON } style = { btnStyle ( false ) } title = "Export as JSON" > JSON</ button >
511+ < button onClick = { exportCSV } style = { btnStyle ( false ) } title = "Export as CSV" > CSV</ button >
512+ </ div >
513+ ) }
382514 </ div >
383515
384516 { /* Selected node info panel */ }
@@ -398,6 +530,13 @@ export default function App() {
398530 < span style = { { color : '#818cf8' } } > { node . tags . map ( ( t ) => t . name || t . primaryCategory ) . join ( ', ' ) } </ span >
399531 ) }
400532 { node . is_stopped && < span style = { { color : '#f59e0b' } } > { node . stop_reason || 'Stopped' } </ span > }
533+ < input
534+ type = "text"
535+ placeholder = "Add note…"
536+ value = { annotations [ node . address ] || '' }
537+ onChange = { ( e ) => setAnnotations ( prev => ( { ...prev , [ node . address ] : e . target . value } ) ) }
538+ style = { { ...inputStyle , fontSize : 11 , padding : '2px 8px' , minWidth : 120 } }
539+ />
401540 < button
402541 onClick = { ( ) => setSelectedAddress ( null ) }
403542 style = { { marginLeft : 'auto' , padding : '2px 8px' , borderRadius : 4 , border : `1px solid ${ c . border } ` , background : 'transparent' , color : c . dimmed , cursor : 'pointer' , fontSize : 11 } }
0 commit comments