@@ -43,6 +43,14 @@ export type CombinedTreeAlignmentProps = {
4343 // Style
4444 fontSize ?: number ;
4545 leafRowSpacing ?: number ;
46+ // Sequence area padding
47+ sequenceTopPadding ?: number ;
48+ sequenceBottomPadding ?: number ;
49+ // Overall container padding
50+ containerTopPadding ?: number ;
51+ containerBottomPadding ?: number ;
52+ // Alignment area padding
53+ alignmentRightPadding ?: number ;
4654 // Dark mode
4755 isDarkMode ?: boolean ;
4856 // Receptor data for GPCRdb numbering (will use conservationFile from receptor object)
@@ -200,12 +208,29 @@ export function CombinedTreeAlignment({
200208 mirrorRightToLeft = false ,
201209 fontSize = 12 ,
202210 leafRowSpacing = 28 ,
211+ sequenceTopPadding = 4 ,
212+ sequenceBottomPadding = 4 ,
213+ containerTopPadding = 0 ,
214+ containerBottomPadding = 0 ,
215+ alignmentRightPadding = 4 ,
203216 isDarkMode,
204217 receptor,
205218} : CombinedTreeAlignmentProps ) {
206219 const containerRef = useRef < HTMLDivElement | null > ( null ) ;
207220 // Mirror site-wide dark mode like other components (SequenceLogoChart, etc.)
208- const [ detectedDarkMode , setDetectedDarkMode ] = useState < boolean > ( false ) ;
221+ // Initialize synchronously from document to avoid a light→dark flash on first paint
222+ const [ detectedDarkMode , setDetectedDarkMode ] = useState < boolean > ( ( ) => {
223+ try {
224+ const html = typeof document !== 'undefined' ? document . documentElement : null ;
225+ const body = typeof document !== 'undefined' ? document . body : null ;
226+ if ( ! html || ! body ) return false ;
227+ const hasDarkClass = html . classList . contains ( 'dark' ) || body . classList . contains ( 'dark' ) ;
228+ const hasDarkData = html . getAttribute ( 'data-theme' ) === 'dark' || body . getAttribute ( 'data-theme' ) === 'dark' ;
229+ return hasDarkClass || hasDarkData ;
230+ } catch {
231+ return false ;
232+ }
233+ } ) ;
209234 useEffect ( ( ) => {
210235 const updateTheme = ( ) => {
211236 try {
@@ -307,17 +332,17 @@ export function CombinedTreeAlignment({
307332 } ) ;
308333 } , [ receptor ?. conservationFile ] ) ;
309334
310- // Autosize to parent if width/height not provided
335+ // Only autosize width if not provided (height is now calculated based on content)
311336 useEffect ( ( ) => {
312- if ( width && height ) return ;
337+ if ( width ) return ;
313338 const el = containerRef . current ;
314339 if ( ! el ) return ;
315- const update = ( ) => setContainerSize ( { w : el . clientWidth || 800 , h : el . clientHeight || 500 } ) ;
340+ const update = ( ) => setContainerSize ( { w : el . clientWidth || 800 , h : containerSize . h } ) ;
316341 update ( ) ;
317342 const ro = new ResizeObserver ( update ) ;
318343 ro . observe ( el ) ;
319344 return ( ) => ro . disconnect ( ) ;
320- } , [ width , height ] ) ;
345+ } , [ width , containerSize . h ] ) ;
321346
322347 const parsedTree = useMemo ( ( ) => {
323348 try {
@@ -332,22 +357,14 @@ export function CombinedTreeAlignment({
332357
333358 // Derived drawing metrics
334359 const treePadding = 16 ; // left padding and bottom padding
335- const treeTopOffset = treePadding + fontSize + 20 ; // extra space for scale bar
336360 // Compact header height for rotated GPCRdb numbers
337361 // alignmentHeaderHeight will be computed dynamically below based on widest label
362+
363+ // Fine-tune vertical gaps relative to the header bottom
364+ const headerToSeqGapPx = Math . max ( 2 , Math . round ( fontSize * 0.5 ) ) ; // slightly larger gap for sequences
338365
339- // Calculate dynamic row spacing to fit container height when size not explicitly provided
340- const dynamicRowSpacing = useMemo ( ( ) => {
341- if ( ! parsedTree || ( width && height ) ) {
342- return leafRowSpacing ; // Use fixed spacing if explicit dimensions provided
343- }
344- const tempLayout = layoutTree ( parsedTree , leafRowSpacing , collapsed ) ;
345- const numVisibleRows = tempLayout . rowNodes . length ;
346- if ( numVisibleRows <= 1 ) return leafRowSpacing ;
347- const availableHeight = containerSize . h - treeTopOffset - treePadding ;
348- const calculatedSpacing = Math . max ( 16 , availableHeight / numVisibleRows ) ;
349- return Math . min ( calculatedSpacing , leafRowSpacing * 2 ) ;
350- } , [ parsedTree , collapsed , containerSize . h , leafRowSpacing , width , height , treeTopOffset , treePadding ] ) ;
366+ // Use fixed row spacing - no dynamic calculation based on container
367+ const dynamicRowSpacing = leafRowSpacing ;
351368
352369 const laidOut = useMemo ( ( ) => {
353370 if ( ! parsedTree ) return null ;
@@ -380,10 +397,11 @@ export function CombinedTreeAlignment({
380397 if ( ! ctx ) return sampleText . length * fontSize * ( 0.6 + LETTER_SPACING_EM ) ; // fallback adjusted for letter spacing
381398 const monoFont = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace" ;
382399 ctx . font = `bold ${ fontSize } px ${ monoFont } ` ;
383- const baseWidth = ctx . measureText ( sampleText ) . width ;
384- // Add letter spacing for each character (0.2em per character)
385- const letterSpacingTotal = sampleText . length * ( fontSize * LETTER_SPACING_EM ) ;
386- return baseWidth + letterSpacingTotal ;
400+ const baseCharWidth = ctx . measureText ( 'M' ) . width ;
401+ const charWidthWithSpacing = baseCharWidth + fontSize * LETTER_SPACING_EM ;
402+ // Calculate width as: (n-1) * charWidthWithSpacing + baseCharWidth
403+ // This accounts for letter spacing between characters but not after the last one
404+ return sampleText . length > 0 ? ( sampleText . length - 1 ) * charWidthWithSpacing + baseCharWidth : 0 ;
387405 } , [ cleanedSequences , fontSize ] ) ;
388406
389407 // Number of columns (characters) to render in the alignment/header
@@ -447,21 +465,33 @@ export function CombinedTreeAlignment({
447465
448466 const contentHeight = useMemo ( ( ) => {
449467 if ( width && height ) return height ;
450- if ( ! laidOut ) return containerSize . h ;
451- // Calculate the exact height needed for the content including header space
452- const desired = laidOut . totalHeight + treePadding + ( cleanedSequences . length > 0 ? alignmentHeaderHeight : 0 ) ;
453- // Only use container height as minimum if content is smaller, otherwise use exact content height
454- return desired > 0 ? desired : containerSize . h ;
455- } , [ laidOut , treePadding , containerSize . h , width , height , cleanedSequences . length , alignmentHeaderHeight ] ) ;
468+ if ( ! laidOut ) return 400 ; // fallback minimum height
469+
470+ // Calculate height based purely on number of sequences/rows
471+ const numSequenceRows = laidOut . visibleLeaves . length ;
472+ const headerPx = cleanedSequences . length > 0 ? alignmentHeaderHeight : 0 ;
473+ const sequenceAreaHeight = numSequenceRows * leafRowSpacing ;
474+
475+ // Total: header + gap + top padding + sequence area + bottom padding + container padding
476+ const totalHeight = headerPx +
477+ headerToSeqGapPx +
478+ sequenceTopPadding +
479+ sequenceAreaHeight +
480+ sequenceBottomPadding +
481+ containerTopPadding +
482+ containerBottomPadding ;
483+
484+ return Math . max ( totalHeight , 200 ) ; // ensure minimum reasonable height
485+ } , [ laidOut , cleanedSequences . length , alignmentHeaderHeight , leafRowSpacing , headerToSeqGapPx , sequenceTopPadding , sequenceBottomPadding , containerTopPadding , containerBottomPadding , width , height ] ) ;
456486
457487 // Height of just the alignment body (rows area) excluding header and external paddings
458488 const alignmentBodyHeight = useMemo ( ( ) => {
459489 if ( ! laidOut ) return 0 ;
460490 const leaves = laidOut . visibleLeaves ;
461491 if ( leaves . length === 0 ) return 0 ;
462492 const maxY = Math . max ( ...leaves . map ( l => l . y || 0 ) ) ;
463- return Math . max ( 0 , maxY + dynamicRowSpacing / 2 ) ;
464- } , [ laidOut , dynamicRowSpacing ] ) ;
493+ return Math . max ( 0 , maxY + dynamicRowSpacing / 2 + sequenceTopPadding + sequenceBottomPadding ) ;
494+ } , [ laidOut , dynamicRowSpacing , sequenceTopPadding , sequenceBottomPadding ] ) ;
465495
466496 // Consistent character width used for both headers and background stripes
467497 const columnCharWidth = useMemo ( ( ) => {
@@ -475,20 +505,22 @@ export function CombinedTreeAlignment({
475505 return baseWidth + fontSize * LETTER_SPACING_EM ;
476506 } , [ fontSize ] ) ;
477507
478- // Colors: responsive to dark mode
479- const backgroundColor = effectiveDarkMode ? '#1f2937' : '#ffffff' ; // gray-800 : white
508+ // Colors: responsive to dark mode - use CSS variables to match other components
509+ const backgroundColor = useMemo ( ( ) => {
510+ if ( typeof document === 'undefined' ) return effectiveDarkMode ? '#2A2A2A' : '#FDFBF7' ;
511+ const computedStyle = getComputedStyle ( document . documentElement ) ;
512+ return computedStyle . getPropertyValue ( '--card' ) . trim ( ) || ( effectiveDarkMode ? '#2A2A2A' : '#FDFBF7' ) ;
513+ } , [ effectiveDarkMode ] ) ;
514+
480515 const strokeColor = effectiveDarkMode ? '#9ca3af' : '#333333' ; // gray-400 : dark gray
481516 const textColor = effectiveDarkMode ? '#f9fafb' : '#111111' ; // gray-50 : almost black
482517 const leafGuideColor = effectiveDarkMode ? '#6b7280' : '#bdbdbd' ; // gray-500 : light gray
483518 const connectorColor = effectiveDarkMode ? '#9ca3af' : '#9e9e9e' ; // gray-400 : medium gray
484519 const alternatingStripeColor = effectiveDarkMode ? '#4b5563' : '#cbd5e1' ; // dark: gray-600, light: slate-300
485- const errorBgColor = effectiveDarkMode ? '#1f2937' : '#ffffff' ; // gray-800 : white
520+ const errorBgColor = backgroundColor ;
486521 const errorTextColor = effectiveDarkMode ? '#ef4444' : '#b91c1c' ; // red-500 : red-700
487522
488- // Fine-tune vertical gaps relative to the header bottom
489- // Note: headerToTreeGapPx is currently unused after aligning tree to sequences; keep for quick tuning if needed.
490- // const headerToTreeGapPx = Math.max(0, Math.round(fontSize * 0.2));
491- const headerToSeqGapPx = Math . max ( 2 , Math . round ( fontSize * 0.5 ) ) ; // slightly larger gap for sequences
523+ // Note: headerToTreeGapPx was removed after aligning tree to sequences
492524
493525 if ( ! parsedTree ) {
494526 return (
@@ -501,12 +533,12 @@ export function CombinedTreeAlignment({
501533 }
502534
503535 const leftWidth = treeWidthPx + Math . max ( 0 , labelsRightX - treeWidthPx ) + treePadding * 2 ;
504- // Use precise measured content width to eliminate empty space
505- const alignmentTotalWidth = alignmentContentWidth ;
506- const totalWidth = leftWidth + alignmentTotalWidth ; // Remove alignPadding gap
536+ // Use precise measured content width plus right padding for better visibility
537+ const alignmentTotalWidth = alignmentContentWidth + alignmentRightPadding ;
538+ const totalWidth = leftWidth + alignmentTotalWidth ;
507539
508540 return (
509- < div ref = { containerRef } style = { { width : width ? `${ width } px` : '100%' , height : height ? `${ height } px` : '100% ' , overflow : 'auto' , position : 'relative' , background : backgroundColor } } >
541+ < div ref = { containerRef } style = { { width : width ? `${ width } px` : '100%' , height : height ? `${ height } px` : 'auto ' , overflow : 'auto' , position : 'relative' , background : backgroundColor } } >
510542 { /* Content area with proper width to avoid empty space on right */ }
511543 < div style = { { position : 'relative' , width : totalWidth , height : contentHeight , background : backgroundColor } } >
512544 { /* Sticky header overlay: GPCRdb column numbers (placed before tree SVG to avoid being pushed down). */ }
@@ -575,7 +607,7 @@ export function CombinedTreeAlignment({
575607 { /* Optional mirror for RTL */ }
576608 < g transform = { mirrorRightToLeft ? `translate(${ leftWidth } ,0) scale(-1,1)` : undefined } >
577609 { /* Tree drawing origin padding: align directly under header to match sequence start offset. */ }
578- < g transform = { `translate(${ treePadding } , ${ alignmentHeaderHeight + headerToSeqGapPx } )` } >
610+ < g transform = { `translate(${ treePadding } , ${ alignmentHeaderHeight + headerToSeqGapPx + sequenceTopPadding } )` } >
579611 { /* Draw branches */ }
580612 { laidOut && (
581613 < TreeBranches
@@ -641,9 +673,9 @@ export function CombinedTreeAlignment({
641673 { /* Background behind sequences to ensure header overlap looks clean */ }
642674 < rect
643675 x = { 0 }
644- y = { - dynamicRowSpacing / 2 }
676+ y = { - sequenceTopPadding }
645677 width = { alignmentTotalWidth }
646- height = { alignmentBodyHeight + dynamicRowSpacing / 2 }
678+ height = { alignmentBodyHeight }
647679 fill = { backgroundColor }
648680 />
649681 { /* Alternating column background stripes */ }
@@ -653,9 +685,9 @@ export function CombinedTreeAlignment({
653685 < rect
654686 key = { `bg-${ i } ` }
655687 x = { i * columnCharWidth }
656- y = { - dynamicRowSpacing / 2 }
688+ y = { - sequenceTopPadding }
657689 width = { columnCharWidth }
658- height = { alignmentBodyHeight + dynamicRowSpacing / 2 }
690+ height = { alignmentBodyHeight }
659691 fill = { alternatingStripeColor } /* Stripe fill */
660692 />
661693 ) : null
@@ -671,6 +703,7 @@ export function CombinedTreeAlignment({
671703 sequences = { cleanedSequences }
672704 fallbackText = { alignmentText }
673705 isDarkMode = { effectiveDarkMode }
706+ topPadding = { sequenceTopPadding }
674707 />
675708 </ g >
676709 </ svg >
@@ -972,6 +1005,7 @@ function AlignmentOnly({
9721005 sequences,
9731006 fallbackText,
9741007 isDarkMode,
1008+ topPadding = 0 ,
9751009} : {
9761010 rowNodes : NewickNode [ ] ;
9771011 leaves : NewickNode [ ] ;
@@ -982,6 +1016,7 @@ function AlignmentOnly({
9821016 sequences : { header : string ; sequence : string } [ ] ;
9831017 fallbackText : string ;
9841018 isDarkMode : boolean ;
1019+ topPadding ?: number ;
9851020} ) {
9861021 const monoFont = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace" ;
9871022 const rows : React . ReactNode [ ] = [ ] ;
@@ -1016,7 +1051,7 @@ function AlignmentOnly({
10161051 } ;
10171052
10181053 leaves . forEach ( ( leaf , idx ) => {
1019- const y = leaf . y || 0 ;
1054+ const y = ( leaf . y || 0 ) + topPadding ;
10201055 const sequence = getSequenceForLeaf ( leaf . name || '' ) ;
10211056
10221057 // Only render if we have a sequence (not null while loading)
0 commit comments