@@ -59,6 +59,18 @@ const REL_COLOR = [
5959// rel codes that make up the dimension layer: VALID_FOR (8) + the facets (10..15).
6060const isFacetRel = ( r : number ) => r === 8 || ( r >= 10 && r <= 15 ) ;
6161
62+ // The 6 dual-use facet AXES in tenant-byte order (value[1..=6]). The SoA tenant
63+ // tail ships one code per axis per node; the facet lens groups nodes by them
64+ // LIVE (the dynamic/residual layer — the twin of the materialized facet edges).
65+ const FACET_AXES_UI = [ 'militaryUse' , 'civicUse' , 'airo:type' , 'MLType' , 'purpose' , 'capacity' ] ;
66+ // categorical palette for facet codes (code 0 = absent → dim slate).
67+ const FACET_PALETTE = [
68+ '#4dd0e1' , '#ffb547' , '#35d07f' , '#9b8cff' , '#ff637d' , '#c792ea' ,
69+ '#7fd1c7' , '#f0a868' , '#6cf0ff' , '#b5e853' , '#ff8fab' , '#8fb8ff' ,
70+ ] ;
71+ const facetColor = ( code : number ) =>
72+ code === 0 ? '#2a3a4a' : FACET_PALETTE [ ( code - 1 ) % FACET_PALETTE . length ] ;
73+
6274const DIM_NODE = { background : 'rgba(10,14,23,0.55)' , border : '#26323f' } ;
6375const DIM_EDGE = 'rgba(50,66,84,0.12)' ;
6476const ACTIVE = '#6cf0ff' ;
@@ -69,6 +81,9 @@ interface Soa {
6981 cls : Uint8Array ;
7082 edges : Array < { s : number ; t : number ; r : number } > ;
7183 labels : string [ ] ;
84+ // per-node facet tenant: 6 codes (value[1..=6]) × nodeCount, or null if the
85+ // asset predates the tenant tail. The dynamic attribute the facet lens groups by.
86+ tenants : Uint8Array | null ;
7287}
7388
7489/** One readable step of the reasoning traversal, streamed into the readout. */
@@ -89,6 +104,7 @@ interface GraphApi {
89104 fireLens : ( angleIdx : number ) => void ;
90105 clear : ( ) => void ;
91106 setDims : ( show : boolean ) => void ;
107+ setFacet : ( axis : number | null ) => void ;
92108}
93109
94110// Decode the OSO1 wire: magic(4) | nodeCount u32 | edgeCount u32 |
@@ -127,7 +143,14 @@ function decodeSoa(buf: ArrayBuffer): Soa {
127143 off += len ;
128144 }
129145 }
130- return { nodeCount, edgeCount, cls, edges, labels } ;
146+ // optional tenant tail (OSO1 additive): node_count × 6 facet bytes (value[1..=6]).
147+ // Old assets stop after the labels; new ones carry the per-node attribute here.
148+ let tenants : Uint8Array | null = null ;
149+ if ( off + nodeCount * 6 <= dv . byteLength ) {
150+ tenants = new Uint8Array ( buf , off , nodeCount * 6 ) ;
151+ off += nodeCount * 6 ;
152+ }
153+ return { nodeCount, edgeCount, cls, edges, labels, tenants } ;
131154}
132155
133156// vis-network options tuned to the Palantir look: hollow ring nodes (dark fill
@@ -266,13 +289,16 @@ export function OsintGraph() {
266289 const hostRef = useRef < HTMLDivElement > ( null ) ;
267290 const netRef = useRef < Network | null > ( null ) ;
268291 const apiRef = useRef < GraphApi | null > ( null ) ;
292+ const facetAxisRef = useRef < number | null > ( null ) ; // mirrors facetAxis for the build closures
269293 const [ soa , setSoa ] = useState < Soa | null > ( null ) ;
270294 const [ error , setError ] = useState < string | null > ( null ) ;
271295 const [ status , setStatus ] = useState ( 'loading SoA…' ) ;
272296 const [ readout , setReadout ] = useState < Readout | null > ( null ) ;
273297 const [ search , setSearch ] = useState ( '' ) ;
274298 const [ angle , setAngle ] = useState < number | null > ( null ) ;
275299 const [ showDims , setShowDims ] = useState ( true ) ;
300+ // active facet lens (0..5 = a FACET_AXES_UI axis, or null = colour by class).
301+ const [ facetAxis , setFacetAxis ] = useState < number | null > ( null ) ;
276302
277303 // Fetch + decode the SoA once.
278304 useEffect ( ( ) => {
@@ -318,14 +344,22 @@ export function OsintGraph() {
318344 const { degree, touched, semantic } = view ;
319345
320346 const baseSize = ( i : number ) => 11 + Math . min ( degree . get ( i ) ?? 1 , 16 ) * 1.5 ;
347+ // facet-lens colouring: when an axis is active, a node's border is its tenant
348+ // code on that axis (categorical, computed live across every node); else the
349+ // class colour. This is the dynamic group-by — no baked edges involved.
350+ const nodeBorder = ( i : number ) => {
351+ const ax = facetAxisRef . current ;
352+ if ( ax != null && soa . tenants ) return facetColor ( soa . tenants [ i * 6 + ax ] ) ;
353+ return classColor ( soa . cls [ i ] ) ;
354+ } ;
321355 const baseNode = ( i : number ) => ( {
322356 id : i ,
323357 label : soa . labels [ i ] || `#${ i } ` ,
324358 color : {
325359 background : 'rgba(10,14,23,0.88)' ,
326- border : classColor ( soa . cls [ i ] ) ,
360+ border : nodeBorder ( i ) ,
327361 highlight : { background : 'rgba(10,14,23,0.96)' , border : '#9fe8ff' } ,
328- hover : { background : 'rgba(10,14,23,0.82)' , border : classColor ( soa . cls [ i ] ) } ,
362+ hover : { background : 'rgba(10,14,23,0.82)' , border : nodeBorder ( i ) } ,
329363 } ,
330364 size : baseSize ( i ) ,
331365 font : { color : '#d9e9f9' } ,
@@ -411,7 +445,7 @@ export function OsintGraph() {
411445 const brighten = ( id : number ) => {
412446 visNodes . update ( {
413447 id,
414- color : { background : 'rgba(10,14,23,0.95)' , border : classColor ( soa . cls [ id ] ) } ,
448+ color : { background : 'rgba(10,14,23,0.95)' , border : nodeBorder ( id ) } ,
415449 font : { color : '#eaf4ff' } ,
416450 } ) ;
417451 } ;
@@ -611,9 +645,18 @@ export function OsintGraph() {
611645 visNodes . update ( schemaNodeIds . map ( ( id ) => ( { id, hidden : ! show } ) ) ) ;
612646 visEdges . update ( schemaEdgeIds . map ( ( id ) => ( { id, hidden : ! show } ) ) ) ;
613647 } ;
614- // apply the current toggle state on (re)build — covers a toggle that landed
615- // before the network (and apiRef) existed, so the button and graph never desync.
648+ // facet lens: recolour every rendered node by its tenant code on `axis`
649+ // (the dynamic group-by across all nodes); null restores the class colours.
650+ // Read-only over the tenant column — no edges, no relayout.
651+ const setFacet = ( axis : number | null ) => {
652+ facetAxisRef . current = axis != null && soa . tenants ? axis : null ;
653+ visNodes . update ( Array . from ( touched ) . map ( baseNode ) ) ;
654+ } ;
655+ // apply the current toggle/lens state on (re)build — covers a toggle that
656+ // landed before the network (and apiRef) existed, so the buttons and graph
657+ // never desync.
616658 setDims ( showDims ) ;
659+ setFacet ( facetAxis ) ;
617660
618661 apiRef . current = {
619662 query : ( text ) => {
@@ -641,6 +684,7 @@ export function OsintGraph() {
641684 setReadout ( null ) ;
642685 } ,
643686 setDims,
687+ setFacet,
644688 } ;
645689
646690 net . on ( 'click' , ( params : { nodes : unknown [ ] } ) => {
@@ -674,6 +718,43 @@ export function OsintGraph() {
674718 setShowDims ( next ) ;
675719 apiRef . current ?. setDims ( next ) ;
676720 } ;
721+ const toggleFacet = ( axis : number ) => {
722+ const next = facetAxis === axis ? null : axis ;
723+ setFacetAxis ( next ) ;
724+ apiRef . current ?. setFacet ( next ) ;
725+ } ;
726+
727+ // live legend for the active facet lens: value→count computed across every
728+ // rendered node from the tenant column, named via the materialized facet
729+ // edges (the two layers reinforcing each other). airo:type is a bitset, so its
730+ // codes read as raw role-masks rather than single values.
731+ const facetLegend = useMemo ( ( ) => {
732+ if ( ! soa || ! soa . tenants || facetAxis == null || ! view ) return null ;
733+ const tenants = soa . tenants ;
734+ const axis = facetAxis ;
735+ const rel = 10 + axis ;
736+ const name = new Map < number , string > ( ) ;
737+ for ( const e of soa . edges ) {
738+ if ( e . r === rel && e . s < soa . nodeCount && e . t < soa . nodeCount ) {
739+ const code = tenants [ e . s * 6 + axis ] ;
740+ if ( code !== 0 && ! name . has ( code ) ) name . set ( code , soa . labels [ e . t ] || `code ${ code } ` ) ;
741+ }
742+ }
743+ const count = new Map < number , number > ( ) ;
744+ let present = 0 ;
745+ view . touched . forEach ( ( i ) => {
746+ const code = tenants [ i * 6 + axis ] ;
747+ if ( code !== 0 ) {
748+ count . set ( code , ( count . get ( code ) ?? 0 ) + 1 ) ;
749+ present += 1 ;
750+ }
751+ } ) ;
752+ const rows = Array . from ( count . entries ( ) )
753+ . sort ( ( a , b ) => b [ 1 ] - a [ 1 ] )
754+ . slice ( 0 , 10 )
755+ . map ( ( [ code , n ] ) => ( { code, n, label : name . get ( code ) ?? `code ${ code } ` } ) ) ;
756+ return { rows, present, distinct : count . size } ;
757+ } , [ soa , facetAxis , view ] ) ;
677758
678759 const lensChip = ( i : number ) : CSSProperties => ( {
679760 fontFamily : 'monospace' ,
@@ -805,13 +886,83 @@ export function OsintGraph() {
805886 </ button >
806887 ) }
807888 </ div >
889+ { /* facet lens — colour every node by a tenant axis, the dynamic group-by */ }
890+ < div style = { { display : 'flex' , flexWrap : 'wrap' , gap : 5 , alignItems : 'center' } } >
891+ < span style = { { fontSize : 10 , color : '#6f87a0' , marginRight : 2 } } > ◐ facet:</ span >
892+ { FACET_AXES_UI . map ( ( ax , i ) => (
893+ < button
894+ key = { ax }
895+ onClick = { ( ) => toggleFacet ( i ) }
896+ title = { `colour every node by its ${ ax } code — a live group-by across all nodes (the tenant column)` }
897+ style = { {
898+ fontFamily : 'monospace' ,
899+ fontSize : 10 ,
900+ color : facetAxis === i ? '#0a0e17' : '#9fb4c8' ,
901+ background : facetAxis === i ? facetColor ( i + 1 ) : 'rgba(17,32,48,0.6)' ,
902+ border : `1px solid ${ facetColor ( i + 1 ) } ` ,
903+ borderRadius : 6 ,
904+ padding : '4px 7px' ,
905+ cursor : 'pointer' ,
906+ fontWeight : facetAxis === i ? 700 : 400 ,
907+ } }
908+ >
909+ { ax }
910+ </ button >
911+ ) ) }
912+ </ div >
808913 < div style = { { fontSize : 10 , color : '#6f87a0' } } >
809914 one entity reasons from it; “A + B” traces the path. click any node to reason.
810915 </ div >
811916 </ div >
812917
813918 { readout && < ReasonBox readout = { readout } onClose = { clearReason } /> }
814919
920+ { /* facet-lens legend — the live group-by over the tenant column */ }
921+ { facetLegend && facetAxis != null && (
922+ < div
923+ style = { {
924+ position : 'absolute' ,
925+ bottom : 16 ,
926+ right : 16 ,
927+ zIndex : 10 ,
928+ fontFamily : 'monospace' ,
929+ fontSize : 11 ,
930+ color : '#cfe7ff' ,
931+ background : 'rgba(8,12,20,0.86)' ,
932+ border : '1px solid #2a4a6a' ,
933+ borderRadius : 8 ,
934+ padding : '8px 10px' ,
935+ maxWidth : 230 ,
936+ maxHeight : '42%' ,
937+ overflowY : 'auto' ,
938+ pointerEvents : 'auto' ,
939+ } }
940+ >
941+ < div style = { { color : '#7fd1ff' , marginBottom : 5 } } >
942+ ◐ { FACET_AXES_UI [ facetAxis ] } · { facetLegend . present } nodes · { facetLegend . distinct } values
943+ </ div >
944+ { facetLegend . rows . map ( ( r ) => (
945+ < div
946+ key = { r . code }
947+ style = { { display : 'flex' , alignItems : 'center' , gap : 6 , whiteSpace : 'nowrap' } }
948+ >
949+ < span
950+ style = { {
951+ display : 'inline-block' ,
952+ width : 9 ,
953+ height : 9 ,
954+ borderRadius : 9 ,
955+ border : `2px solid ${ facetColor ( r . code ) } ` ,
956+ flex : '0 0 auto' ,
957+ } }
958+ />
959+ < span style = { { overflow : 'hidden' , textOverflow : 'ellipsis' } } > { r . label } </ span >
960+ < span style = { { color : '#7f97b0' , marginLeft : 'auto' } } > { r . n } </ span >
961+ </ div >
962+ ) ) }
963+ </ div >
964+ ) }
965+
815966 { /* class legend */ }
816967 < div
817968 style = { {
0 commit comments