@@ -43,6 +43,8 @@ export type CombinedTreeAlignmentProps = {
4343 // Style
4444 fontSize ?: number ;
4545 leafRowSpacing ?: number ;
46+ // Dark mode
47+ isDarkMode ?: boolean ;
4648 // Receptor data for GPCRdb numbering (will use conservationFile from receptor object)
4749 receptor ?: { conservationFile ?: string } | null ;
4850} ;
@@ -198,9 +200,36 @@ export function CombinedTreeAlignment({
198200 mirrorRightToLeft = false ,
199201 fontSize = 12 ,
200202 leafRowSpacing = 28 ,
203+ isDarkMode,
201204 receptor,
202205} : CombinedTreeAlignmentProps ) {
203206 const containerRef = useRef < HTMLDivElement | null > ( null ) ;
207+ // Mirror site-wide dark mode like other components (SequenceLogoChart, etc.)
208+ const [ detectedDarkMode , setDetectedDarkMode ] = useState < boolean > ( false ) ;
209+ useEffect ( ( ) => {
210+ const updateTheme = ( ) => {
211+ try {
212+ const html = document . documentElement ;
213+ const body = document . body ;
214+ const hasDarkClass = html . classList . contains ( 'dark' ) || body . classList . contains ( 'dark' ) ;
215+ const hasDarkData = html . getAttribute ( 'data-theme' ) === 'dark' || body . getAttribute ( 'data-theme' ) === 'dark' ;
216+ setDetectedDarkMode ( hasDarkClass || hasDarkData ) ;
217+ } catch {
218+ setDetectedDarkMode ( false ) ;
219+ }
220+ } ;
221+ updateTheme ( ) ;
222+ const htmlObserver = new MutationObserver ( updateTheme ) ;
223+ const bodyObserver = new MutationObserver ( updateTheme ) ;
224+ htmlObserver . observe ( document . documentElement , { attributes : true , attributeFilter : [ 'class' , 'data-theme' ] } ) ;
225+ if ( document . body ) bodyObserver . observe ( document . body , { attributes : true , attributeFilter : [ 'class' , 'data-theme' ] } ) ;
226+ return ( ) => {
227+ htmlObserver . disconnect ( ) ;
228+ bodyObserver . disconnect ( ) ;
229+ } ;
230+ } , [ ] ) ;
231+ // Effective dark mode: explicit prop wins when provided; otherwise use detected
232+ const effectiveDarkMode = typeof isDarkMode === 'boolean' ? isDarkMode : detectedDarkMode ;
204233 const [ containerSize , setContainerSize ] = useState < { w : number ; h : number } > ( { w : width || 800 , h : height || 500 } ) ;
205234
206235 // Parse FASTA and use the cleaning hook
@@ -390,7 +419,7 @@ export function CombinedTreeAlignment({
390419 const canvas = document . createElement ( 'canvas' ) ;
391420 const ctx = canvas . getContext ( '2d' ) ;
392421 if ( ! ctx ) return laidOut . visibleLeaves . map ( ( ) => 0 ) ;
393- ctx . font = `${ fontSize } px sans-serif` ;
422+ ctx . font = `${ fontSize } px Arial, sans-serif` ;
394423 return laidOut . visibleLeaves . map ( leaf => {
395424 const name = leaf . name || '' ;
396425 const parts = name . split ( '|' ) ;
@@ -425,6 +454,15 @@ export function CombinedTreeAlignment({
425454 return desired > 0 ? desired : containerSize . h ;
426455 } , [ laidOut , treePadding , containerSize . h , width , height , cleanedSequences . length , alignmentHeaderHeight ] ) ;
427456
457+ // Height of just the alignment body (rows area) excluding header and external paddings
458+ const alignmentBodyHeight = useMemo ( ( ) => {
459+ if ( ! laidOut ) return 0 ;
460+ const leaves = laidOut . visibleLeaves ;
461+ if ( leaves . length === 0 ) return 0 ;
462+ const maxY = Math . max ( ...leaves . map ( l => l . y || 0 ) ) ;
463+ return Math . max ( 0 , maxY + dynamicRowSpacing / 2 ) ;
464+ } , [ laidOut , dynamicRowSpacing ] ) ;
465+
428466 // Consistent character width used for both headers and background stripes
429467 const columnCharWidth = useMemo ( ( ) => {
430468 const canvas = document . createElement ( 'canvas' ) ;
@@ -437,11 +475,15 @@ export function CombinedTreeAlignment({
437475 return baseWidth + fontSize * LETTER_SPACING_EM ;
438476 } , [ fontSize ] ) ;
439477
440- // Colors: charcoal on white background
441- const strokeColor = '#333333' ;
442- const textColor = '#111111' ;
443- const leafGuideColor = '#bdbdbd' ;
444- const connectorColor = '#9e9e9e' ;
478+ // Colors: responsive to dark mode
479+ const backgroundColor = effectiveDarkMode ? '#1f2937' : '#ffffff' ; // gray-800 : white
480+ const strokeColor = effectiveDarkMode ? '#9ca3af' : '#333333' ; // gray-400 : dark gray
481+ const textColor = effectiveDarkMode ? '#f9fafb' : '#111111' ; // gray-50 : almost black
482+ const leafGuideColor = effectiveDarkMode ? '#6b7280' : '#bdbdbd' ; // gray-500 : light gray
483+ const connectorColor = effectiveDarkMode ? '#9ca3af' : '#9e9e9e' ; // gray-400 : medium gray
484+ const alternatingStripeColor = effectiveDarkMode ? '#4b5563' : '#cbd5e1' ; // dark: gray-600, light: slate-300
485+ const errorBgColor = effectiveDarkMode ? '#1f2937' : '#ffffff' ; // gray-800 : white
486+ const errorTextColor = effectiveDarkMode ? '#ef4444' : '#b91c1c' ; // red-500 : red-700
445487
446488 // Fine-tune vertical gaps relative to the header bottom
447489 // Note: headerToTreeGapPx is currently unused after aligning tree to sequences; keep for quick tuning if needed.
@@ -451,7 +493,7 @@ export function CombinedTreeAlignment({
451493 if ( ! parsedTree ) {
452494 return (
453495 < div ref = { containerRef } style = { { width : width ? `${ width } px` : '100%' , height : height ? `${ height } px` : '420px' } } >
454- < div style = { { background : '#ffffff' , color : '#b91c1c' , padding : 8 , border : ' 1px solid # e5e7eb' } } >
496+ < div style = { { background : errorBgColor , color : errorTextColor , padding : 8 , border : ` 1px solid ${ isDarkMode ? '#4b5563' : '# e5e7eb'} ` } } >
455497 Invalid Newick provided.
456498 </ div >
457499 </ div >
@@ -464,16 +506,16 @@ export function CombinedTreeAlignment({
464506 const totalWidth = leftWidth + alignmentTotalWidth ; // Remove alignPadding gap
465507
466508 return (
467- < div ref = { containerRef } style = { { width : width ? `${ width } px` : '100%' , height : height ? `${ height } px` : '100%' , overflow : 'auto' , position : 'relative' , background : '#ffffff' } } >
509+ < div ref = { containerRef } style = { { width : width ? `${ width } px` : '100%' , height : height ? `${ height } px` : '100%' , overflow : 'auto' , position : 'relative' , background : backgroundColor } } >
468510 { /* Content area with proper width to avoid empty space on right */ }
469- < div style = { { position : 'relative' , width : totalWidth , height : contentHeight , background : '#ffffff' } } >
511+ < div style = { { position : 'relative' , width : totalWidth , height : contentHeight , background : backgroundColor } } >
470512 { /* Sticky header overlay: GPCRdb column numbers (placed before tree SVG to avoid being pushed down). */ }
471513 { laidOut && (
472514 // Make the header sticky without affecting flow: height 0 + overflow visible prevents pushing tree down
473515 < div style = { { position : 'sticky' , top : 0 , left : leftWidth , zIndex : 15 , width : alignmentTotalWidth , height : 0 , pointerEvents : 'none' , overflow : 'visible' } } >
474516 < svg width = { alignmentTotalWidth } height = { alignmentHeaderHeight } viewBox = { `0 0 ${ alignmentTotalWidth } ${ alignmentHeaderHeight } ` } style = { { display : 'block' } } >
475- { /* Solid white background to ensure header is opaque */ }
476- < rect x = { 0 } y = { 0 } width = { alignmentTotalWidth } height = { alignmentHeaderHeight } fill = "#ffffff" />
517+ { /* Solid background to ensure header is opaque */ }
518+ < rect x = { 0 } y = { 0 } width = { alignmentTotalWidth } height = { alignmentHeaderHeight } fill = { backgroundColor } />
477519 { /* Alternating background stripes behind GPCRdb header */ }
478520 < g >
479521 { Array . from ( { length : numColumns } ) . map ( ( _ , i ) => (
@@ -484,7 +526,7 @@ export function CombinedTreeAlignment({
484526 y = { 0 }
485527 width = { columnCharWidth }
486528 height = { alignmentHeaderHeight }
487- fill = "#f3f4f6"
529+ fill = { alternatingStripeColor }
488530 />
489531 ) : null
490532 ) ) }
@@ -510,12 +552,12 @@ export function CombinedTreeAlignment({
510552 width = { leftWidth }
511553 height = { contentHeight }
512554 viewBox = { `0 0 ${ leftWidth } ${ Math . max ( contentHeight , 10 ) } ` }
513- style = { { position : 'sticky' , left : 0 , top : 0 , zIndex : 20 , background : '#ffffff' } }
555+ style = { { position : 'sticky' , left : 0 , top : 0 , zIndex : 20 , background : backgroundColor } }
514556 >
515- { /* White background for entire tree column (also covers the sticky header area above content) */ }
516- < rect x = { 0 } y = { 0 } width = { leftWidth } height = { Math . max ( contentHeight , 10 ) } fill = "#ffffff" />
557+ { /* Background for entire tree column (also covers the sticky header area above content) */ }
558+ < rect x = { 0 } y = { 0 } width = { leftWidth } height = { Math . max ( contentHeight , 10 ) } fill = { backgroundColor } />
517559 { /* Top cap to ensure header disappears behind tree when scrolling right */ }
518- < rect x = { 0 } y = { 0 } width = { leftWidth } height = { alignmentHeaderHeight } fill = "#ffffff" />
560+ < rect x = { 0 } y = { 0 } width = { leftWidth } height = { alignmentHeaderHeight } fill = { backgroundColor } />
519561 { /* Scale bar (top-left) */ }
520562 { laidOut && (
521563 < SimpleScaleBar
@@ -543,6 +585,7 @@ export function CombinedTreeAlignment({
543585 showSupport = { showSupportOnBranches }
544586 textColor = { textColor }
545587 collapsed = { collapsed }
588+ backgroundColor = { backgroundColor }
546589 />
547590 ) }
548591
@@ -560,6 +603,7 @@ export function CombinedTreeAlignment({
560603 return next ;
561604 } )
562605 }
606+ backgroundColor = { backgroundColor }
563607 />
564608 ) }
565609
@@ -594,13 +638,13 @@ export function CombinedTreeAlignment({
594638 >
595639 { /* Alignment content: start under header with adjustable gap */ }
596640 < g transform = { `translate(0, ${ alignmentHeaderHeight + headerToSeqGapPx } )` } >
597- { /* White background behind sequences to ensure header overlap looks clean */ }
641+ { /* Background behind sequences to ensure header overlap looks clean */ }
598642 < rect
599643 x = { 0 }
600- y = { 0 }
644+ y = { - dynamicRowSpacing / 2 }
601645 width = { alignmentTotalWidth }
602- height = { Math . max ( 0 , contentHeight - alignmentHeaderHeight - headerToSeqGapPx ) }
603- fill = "#ffffff"
646+ height = { alignmentBodyHeight + dynamicRowSpacing / 2 }
647+ fill = { backgroundColor }
604648 />
605649 { /* Alternating column background stripes */ }
606650 < g >
@@ -609,10 +653,10 @@ export function CombinedTreeAlignment({
609653 < rect
610654 key = { `bg-${ i } ` }
611655 x = { i * columnCharWidth }
612- y = { 0 }
656+ y = { - dynamicRowSpacing / 2 }
613657 width = { columnCharWidth }
614- height = { Math . max ( 0 , contentHeight - alignmentHeaderHeight - headerToSeqGapPx ) }
615- fill = "#f3f4f6" /* Tailwind gray-100 */
658+ height = { alignmentBodyHeight + dynamicRowSpacing / 2 }
659+ fill = { alternatingStripeColor } /* Stripe fill */
616660 />
617661 ) : null
618662 ) ) }
@@ -626,6 +670,7 @@ export function CombinedTreeAlignment({
626670 fontSize = { fontSize }
627671 sequences = { cleanedSequences }
628672 fallbackText = { alignmentText }
673+ isDarkMode = { effectiveDarkMode }
629674 />
630675 </ g >
631676 </ svg >
@@ -642,13 +687,15 @@ function TreeBranches({
642687 showSupport,
643688 textColor,
644689 collapsed,
690+ backgroundColor,
645691} : {
646692 node : NewickNode ;
647693 strokeColor : string ;
648694 fontSize : number ;
649695 showSupport : boolean ;
650696 textColor : string ;
651697 collapsed : Set < string > ;
698+ backgroundColor : string ;
652699} ) {
653700 const elements : React . ReactNode [ ] = [ ] ;
654701
@@ -693,8 +740,8 @@ function TreeBranches({
693740 height = { rectH }
694741 rx = { 3 }
695742 ry = { 3 }
696- fill = "#ffffff"
697- opacity = { 0.80 }
743+ fill = { backgroundColor }
744+ opacity = { 0.90 }
698745 /> ,
699746 ) ;
700747 elements . push (
@@ -768,6 +815,7 @@ function LeafLabels({
768815 y = { y }
769816 fontSize = { fontSize }
770817 fill = { textColor }
818+ fontFamily = "Arial, sans-serif"
771819 textAnchor = "end"
772820 dominantBaseline = "middle"
773821 transform = { mirror ? `scale(-1,1) translate(${ - 2 * rightRailX } ,0)` : undefined }
@@ -794,12 +842,14 @@ function NodeCircles({
794842 strokeColor,
795843 collapsed,
796844 onToggle,
845+ backgroundColor,
797846} : {
798847 node : NewickNode ;
799848 radius : number ;
800849 strokeColor : string ;
801850 collapsed : Set < string > ;
802851 onToggle : ( id : string ) => void ;
852+ backgroundColor : string ;
803853} ) {
804854 const elements : React . ReactNode [ ] = [ ] ;
805855
@@ -817,7 +867,7 @@ function NodeCircles({
817867 cx = { cx }
818868 cy = { cy }
819869 r = { radius }
820- fill = { isCollapsed ? strokeColor : '#ffffff' }
870+ fill = { isCollapsed ? strokeColor : backgroundColor }
821871 stroke = { strokeColor }
822872 strokeWidth = { 1 }
823873 style = { { cursor : 'pointer' } }
@@ -868,7 +918,7 @@ function AlignmentColumnHeaders({
868918
869919 // charWidth provided by parent for consistency with backgrounds
870920
871- const monoFont = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace " ;
921+ const headerFont = "Arial, sans-serif " ;
872922 // Anchor labels to the bottom of the header so rotation doesn't clip at the top
873923 const yAnchor = Math . max ( 2 , headerHeight - 2 ) ;
874924
@@ -895,7 +945,7 @@ function AlignmentColumnHeaders({
895945 y = { yAnchor }
896946 fontSize = { fontSize }
897947 fill = { textColor }
898- fontFamily = { monoFont }
948+ fontFamily = { headerFont }
899949 textAnchor = "start"
900950 dominantBaseline = "hanging"
901951 transform = { `rotate(-90, ${ x } , ${ yAnchor } )` }
@@ -921,6 +971,7 @@ function AlignmentOnly({
921971 fontSize,
922972 sequences,
923973 fallbackText,
974+ isDarkMode,
924975} : {
925976 rowNodes : NewickNode [ ] ;
926977 leaves : NewickNode [ ] ;
@@ -930,6 +981,7 @@ function AlignmentOnly({
930981 fontSize : number ;
931982 sequences : { header : string ; sequence : string } [ ] ;
932983 fallbackText : string ;
984+ isDarkMode : boolean ;
933985} ) {
934986 const monoFont = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace" ;
935987 const rows : React . ReactNode [ ] = [ ] ;
@@ -988,7 +1040,7 @@ function AlignmentOnly({
9881040 x = { xStart + i * charWidth + charWidth / 2 }
9891041 dy = { 0 }
9901042 textAnchor = "middle"
991- fill = { residueColor ( residue ) }
1043+ fill = { residueColor ( residue , isDarkMode ) }
9921044 >
9931045 { residue }
9941046 </ tspan >
@@ -1000,15 +1052,28 @@ function AlignmentOnly({
10001052
10011053 return < g > { rows } </ g > ;
10021054}
1003- function residueColor ( residue : string ) : string | undefined {
1004- const mapping : Record < string , string > = {
1055+ function residueColor ( residue : string , isDarkMode : boolean = false ) : string | undefined {
1056+ // Light mode colors
1057+ const lightMapping : Record < string , string > = {
10051058 FCB315 : 'WYHF' ,
10061059 '7D2985' : 'STQN' ,
10071060 '231F20' : 'PGA' ,
10081061 DD6030 : 'ED' ,
10091062 '7CAEC4' : 'RK' ,
10101063 B4B4B4 : 'VCIML' ,
10111064 } ;
1065+
1066+ // Dark mode colors (adjusted for better contrast)
1067+ const darkMapping : Record < string , string > = {
1068+ 'FFD700' : 'WYHF' , // Gold for aromatic
1069+ 'DA70D6' : 'STQN' , // Orchid for polar
1070+ 'F0F0F0' : 'PGA' , // Light gray for small (inverted)
1071+ 'FF6347' : 'ED' , // Tomato for acidic
1072+ '87CEEB' : 'RK' , // Sky blue for basic
1073+ 'C0C0C0' : 'VCIML' , // Silver for hydrophobic
1074+ } ;
1075+
1076+ const mapping = isDarkMode ? darkMapping : lightMapping ;
10121077 const ch = residue . toUpperCase ( ) ;
10131078 for ( const [ hex , acids ] of Object . entries ( mapping ) ) {
10141079 if ( acids . includes ( ch ) ) return `#${ hex } ` ;
0 commit comments