@@ -74,6 +74,75 @@ function tierPos(
7474 return { x : ( ( lo + hi ) / 2 ) * COL , y : soa . cls [ i ] * ROW } ;
7575}
7676
77+ const isCeiling = ( soa : Soa , i : number ) => soa . ceiling [ i ] === 1 || soa . cls [ i ] === 5 ;
78+
79+ // A node n is INSIDE container c's wall iff its 8:8 tier instances match c's down
80+ // to c's depth — i.e. c's address is a prefix of n's. The address IS the wall.
81+ function inside ( soa : Soa , c : number , n : number ) : boolean {
82+ const d = soa . cls [ c ] ;
83+ if ( soa . cls [ n ] < d ) return false ;
84+ if ( d >= 1 && inst ( soa . hip [ n ] ) !== inst ( soa . hip [ c ] ) ) return false ;
85+ if ( d >= 2 && inst ( soa . twig [ n ] ) !== inst ( soa . twig [ c ] ) ) return false ;
86+ if ( d >= 3 && inst ( soa . leaf [ n ] ) !== inst ( soa . leaf [ c ] ) ) return false ;
87+ return true ;
88+ }
89+
90+ interface Wall {
91+ x0 : number ;
92+ y0 : number ;
93+ x1 : number ;
94+ y1 : number ;
95+ color : string ;
96+ depth : number ;
97+ }
98+
99+ // One nested "outer wall" rectangle per container (Organ→Tissue; cells are
100+ // leaves). Each box bounds its tier-prefix descendants, so the walls nest exactly
101+ // like the partonomy — the Heart's box is the outermost wall (its epicardium).
102+ function containerWalls ( soa : Soa ) : Wall [ ] {
103+ const center = new Map < number , { x : number ; y : number } > ( ) ;
104+ for ( let i = 0 ; i < soa . nodeCount ; i ++ ) if ( ! isCeiling ( soa , i ) ) center . set ( i , tierPos ( soa , i ) ) ;
105+ const walls : Wall [ ] = [ ] ;
106+ for ( let c = 0 ; c < soa . nodeCount ; c ++ ) {
107+ if ( isCeiling ( soa , c ) || soa . cls [ c ] > 3 ) continue ; // only Organ..Tissue contain
108+ let x0 = Infinity ;
109+ let y0 = Infinity ;
110+ let x1 = - Infinity ;
111+ let y1 = - Infinity ;
112+ let found = false ;
113+ for ( const [ n , p ] of center ) {
114+ if ( ! inside ( soa , c , n ) ) continue ;
115+ found = true ;
116+ x0 = Math . min ( x0 , p . x ) ;
117+ y0 = Math . min ( y0 , p . y ) ;
118+ x1 = Math . max ( x1 , p . x ) ;
119+ y1 = Math . max ( y1 , p . y ) ;
120+ }
121+ if ( ! found ) continue ;
122+ const pad = 42 - soa . cls [ c ] * 7 ; // coarser container → roomier wall
123+ walls . push ( { x0 : x0 - pad , y0 : y0 - pad , x1 : x1 + pad , y1 : y1 + pad , color : classColor ( soa . cls [ c ] ) , depth : soa . cls [ c ] } ) ;
124+ }
125+ return walls . sort ( ( a , b ) => a . depth - b . depth ) ; // coarsest drawn first (behind)
126+ }
127+
128+ // Stroke a rounded rect in vis-network coordinates (the beforeDrawing ctx is
129+ // already in network space, so it aligns with node positions).
130+ function strokeWall ( ctx : CanvasRenderingContext2D , w : Wall ) : void {
131+ const r = 16 ;
132+ ctx . beginPath ( ) ;
133+ ctx . moveTo ( w . x0 + r , w . y0 ) ;
134+ ctx . arcTo ( w . x1 , w . y0 , w . x1 , w . y1 , r ) ;
135+ ctx . arcTo ( w . x1 , w . y1 , w . x0 , w . y1 , r ) ;
136+ ctx . arcTo ( w . x0 , w . y1 , w . x0 , w . y0 , r ) ;
137+ ctx . arcTo ( w . x0 , w . y0 , w . x1 , w . y0 , r ) ;
138+ ctx . closePath ( ) ;
139+ ctx . fillStyle = `${ w . color } 0f` ; // very faint compartment fill
140+ ctx . fill ( ) ;
141+ ctx . lineWidth = w . depth === 0 ? 2.6 : 1.4 ; // the Heart's outer wall is boldest
142+ ctx . strokeStyle = `${ w . color } 66` ;
143+ ctx . stroke ( ) ;
144+ }
145+
77146const OPTIONS : Options = {
78147 nodes : { shape : 'dot' , borderWidth : 2.5 , font : { color : '#d9e9f9' , size : 13 , strokeWidth : 3 , strokeColor : PAGE_BG } } ,
79148 edges : {
@@ -183,6 +252,12 @@ export function FmaGraph() {
183252 const visNodes = new DataSet < any > ( Array . from ( { length : soa . nodeCount } , ( _ , i ) => baseNode ( i ) ) ) ;
184253 const visEdges = new DataSet < any > ( soa . edges . map ( ( e , id ) => baseEdge ( e , id ) ) ) ;
185254 const net = new Network ( hostRef . current , { nodes : visNodes , edges : visEdges } , OPTIONS ) ;
255+ // nested "outer walls" — one rounded box per container, drawn behind the
256+ // nodes; they nest exactly like the partonomy (the address IS the wall).
257+ const walls = containerWalls ( soa ) ;
258+ net . on ( 'beforeDrawing' , ( ctx : CanvasRenderingContext2D ) => {
259+ for ( const w of walls ) strokeWall ( ctx , w ) ;
260+ } ) ;
186261 // fixed 8:8-tier slots, no simulation — just frame the nested cascade.
187262 net . once ( 'afterDrawing' , ( ) => net . fit ( { animation : false } ) ) ;
188263 setStatus ( `${ soa . nodeCount } nodes · ${ soa . edgeCount } edges — Z-order tile pyramid; click a tissue for its dual membership` ) ;
0 commit comments