2026-05-18 17:11:36 +02:00
from __future__ import annotations
def graph_explorer_page ( ) - > str :
return """ <!doctype html>
< html lang = " en " >
< head >
< meta charset = " utf-8 " >
< meta name = " viewport " content = " width=device-width, initial-scale=1 " >
< title > Fabric Map < / title >
< style >
: root {
color - scheme : light ;
font - family : Inter , ui - sans - serif , system - ui , - apple - system , BlinkMacSystemFont , " Segoe UI " , sans - serif ;
- - bg : #f7f8fa;
- - panel : #ffffff;
- - line : #d6dce5;
- - text : #172033;
- - muted : #667085;
- - accent : #0f766e;
- - accent - 2 : #2563eb;
- - warn : #b45309;
}
* { box - sizing : border - box ; }
html , body { height : 100 % ; margin : 0 ; }
body { background : var ( - - bg ) ; color : var ( - - text ) ; font - size : 14 px ; }
. shell { display : grid ; grid - template - rows : auto 1 fr ; height : 100 vh ; min - height : 620 px ; }
. toolbar {
display : grid ;
2026-05-18 23:58:58 +02:00
grid - template - columns : minmax ( 180 px , 1.2 fr ) repeat ( 7 , minmax ( 108 px , .55 fr ) ) auto ;
2026-05-18 17:11:36 +02:00
gap : 8 px ;
align - items : center ;
padding : 10 px 12 px ;
border - bottom : 1 px solid var ( - - line ) ;
background : var ( - - panel ) ;
}
. main { display : grid ; grid - template - columns : minmax ( 0 , 1 fr ) 340 px ; min - height : 0 ; }
. canvas - wrap { position : relative ; min - width : 0 ; min - height : 0 ; }
#graph-canvas { position: absolute; inset: 0; }
. side {
border - left : 1 px solid var ( - - line ) ;
background : var ( - - panel ) ;
min - height : 0 ;
overflow : auto ;
padding : 14 px ;
}
. side h1 { font - size : 17 px ; line - height : 1.2 ; margin : 0 0 10 px ; }
. section { border - top : 1 px solid var ( - - line ) ; padding - top : 12 px ; margin - top : 12 px ; }
. section : first - child { border - top : 0 ; padding - top : 0 ; margin - top : 0 ; }
2026-05-18 23:58:58 +02:00
label , . field { color : var ( - - muted ) ; display : grid ; gap : 4 px ; font - size : 12 px ; }
2026-05-18 17:11:36 +02:00
input , select , button {
border : 1 px solid var ( - - line ) ;
border - radius : 6 px ;
color : var ( - - text ) ;
font : inherit ;
min - height : 34 px ;
padding : 6 px 8 px ;
background : #ffffff;
}
button {
cursor : pointer ;
display : inline - flex ;
align - items : center ;
justify - content : center ;
gap : 6 px ;
white - space : nowrap ;
}
button . primary { background : var ( - - accent ) ; border - color : var ( - - accent ) ; color : #ffffff; }
button : disabled { color : #98a2b3; cursor: default; }
2026-05-19 00:51:38 +02:00
. field - label {
display : inline - flex ;
align - items : center ;
gap : 5 px ;
}
. help - tip {
width : 18 px ;
height : 18 px ;
min - height : 18 px ;
padding : 0 ;
border - radius : 50 % ;
color : var ( - - muted ) ;
font - size : 11 px ;
line - height : 1 ;
background : #f8fafc;
}
. help - tip : hover , . help - tip : focus - visible {
border - color : var ( - - accent ) ;
color : var ( - - accent ) ;
outline : none ;
}
. help - popup {
position : fixed ;
z - index : 30 ;
display : none ;
max - width : 300 px ;
border : 1 px solid var ( - - line ) ;
border - radius : 8 px ;
background : #ffffff;
box - shadow : 0 16 px 40 px rgba ( 23 , 32 , 51 , .16 ) ;
color : var ( - - text ) ;
padding : 10 px ;
pointer - events : none ;
}
. help - popup strong { display : block ; margin - bottom : 4 px ; }
. help - popup p { margin : 0 ; color : var ( - - muted ) ; font - size : 12 px ; line - height : 1.35 ; }
2026-05-18 23:58:58 +02:00
. filter - menu {
position : relative ;
min - width : 0 ;
}
. filter - menu summary {
border : 1 px solid var ( - - line ) ;
border - radius : 6 px ;
color : var ( - - text ) ;
cursor : pointer ;
list - style : none ;
min - height : 34 px ;
overflow : hidden ;
padding : 6 px 8 px ;
text - overflow : ellipsis ;
white - space : nowrap ;
background : #ffffff;
}
. filter - menu summary : : - webkit - details - marker { display : none ; }
. filter - menu [ open ] summary { border - color : var ( - - accent ) ; }
. check - list {
position : absolute ;
z - index : 8 ;
display : grid ;
gap : 4 px ;
width : max - content ;
min - width : 210 px ;
max - width : 280 px ;
max - height : 300 px ;
overflow : auto ;
margin - top : 4 px ;
padding : 8 px ;
border : 1 px solid var ( - - line ) ;
border - radius : 8 px ;
background : #ffffff;
box - shadow : 0 16 px 40 px rgba ( 23 , 32 , 51 , .14 ) ;
}
. check - list label {
grid - template - columns : auto 1 fr ;
align - items : center ;
color : var ( - - text ) ;
font - size : 13 px ;
gap : 8 px ;
min - height : 26 px ;
}
. check - list input {
min - height : 0 ;
padding : 0 ;
}
. map - controls {
position : absolute ;
top : 12 px ;
right : 12 px ;
z - index : 3 ;
display : flex ;
2026-05-19 00:27:09 +02:00
align - items : end ;
2026-05-19 00:51:38 +02:00
flex - wrap : wrap ;
justify - content : flex - end ;
2026-05-18 23:58:58 +02:00
gap : 6 px ;
2026-05-19 00:51:38 +02:00
max - width : min ( 460 px , calc ( 100 % - 24 px ) ) ;
2026-05-18 23:58:58 +02:00
padding : 6 px ;
border : 1 px solid var ( - - line ) ;
border - radius : 8 px ;
background : rgba ( 255 , 255 , 255 , .94 ) ;
box - shadow : 0 12 px 30 px rgba ( 23 , 32 , 51 , .12 ) ;
}
. map - controls button { min - width : 54 px ; }
2026-05-19 00:27:09 +02:00
. map - control - field {
display : grid ;
gap : 2 px ;
color : var ( - - muted ) ;
font - size : 11 px ;
line - height : 1 ;
}
. map - control - field select {
min - height : 30 px ;
min - width : 84 px ;
padding : 4 px 6 px ;
}
2026-05-19 00:16:57 +02:00
. selection - anchor {
position : absolute ;
z - index : 5 ;
display : none ;
width : 0 ;
height : 0 ;
pointer - events : none ;
transform : translate ( - 50 % , - 100 % ) ;
}
. selection - anchor : : before {
content : " " ;
position : absolute ;
left : - 1 px ;
top : - 36 px ;
width : 2 px ;
height : 28 px ;
border - radius : 999 px ;
background : #111827;
box - shadow : 0 1 px 2 px rgba ( 23 , 32 , 51 , .28 ) ;
}
. selection - anchor : : after {
content : " " ;
position : absolute ;
left : - 6 px ;
top : - 10 px ;
width : 0 ;
height : 0 ;
border - left : 6 px solid transparent ;
border - right : 6 px solid transparent ;
border - top : 10 px solid #111827;
filter : drop - shadow ( 0 1 px 1 px rgba ( 23 , 32 , 51 , .24 ) ) ;
}
. selection - anchor - label {
position : absolute ;
left : 12 px ;
top : - 48 px ;
max - width : 220 px ;
overflow : hidden ;
border : 1 px solid var ( - - line ) ;
border - radius : 8 px ;
background : rgba ( 255 , 255 , 255 , .96 ) ;
box - shadow : 0 12 px 30 px rgba ( 23 , 32 , 51 , .12 ) ;
color : var ( - - text ) ;
font - size : 12 px ;
line - height : 1.25 ;
padding : 6 px 8 px ;
text - overflow : ellipsis ;
white - space : nowrap ;
}
2026-05-18 17:11:36 +02:00
. button - row { display : flex ; flex - wrap : wrap ; gap : 8 px ; }
. meta { color : var ( - - muted ) ; font - size : 12 px ; margin : 0 ; }
. pill {
display : inline - flex ;
align - items : center ;
border : 1 px solid var ( - - line ) ;
border - radius : 999 px ;
color : var ( - - muted ) ;
min - height : 24 px ;
padding : 2 px 8 px ;
margin : 0 4 px 4 px 0 ;
font - size : 12 px ;
}
. detail - list { display : grid ; gap : 6 px ; margin : 8 px 0 0 ; padding : 0 ; list - style : none ; }
. detail - list li { overflow - wrap : anywhere ; }
2026-05-19 01:46:23 +02:00
. orientation - list { gap : 8 px ; }
. orientation - item {
border : 1 px solid var ( - - line ) ;
border - radius : 8 px ;
background : #fbfcfe;
padding : 8 px ;
}
. orientation - label {
color : var ( - - muted ) ;
display : block ;
font - size : 11 px ;
margin - bottom : 3 px ;
text - transform : uppercase ;
}
. orientation - value { color : var ( - - text ) ; line - height : 1.35 ; }
. orientation - path { font - family : ui - monospace , SFMono - Regular , Menlo , Consolas , monospace ; font - size : 12 px ; }
. orientation - warning { border - color : #f59e0b; background: #fffbeb; }
. orientation - good { border - color : #5eead4; background: #f0fdfa; }
2026-05-19 01:16:18 +02:00
. rule - panel {
border : 1 px solid var ( - - line ) ;
border - radius : 8 px ;
background : #fbfcfe;
}
. rule - panel summary {
cursor : pointer ;
list - style : none ;
padding : 10 px ;
}
. rule - panel summary : : - webkit - details - marker { display : none ; }
. rule - editor {
display : grid ;
gap : 8 px ;
padding : 0 10 px 10 px ;
}
. rule - grid {
display : grid ;
grid - template - columns : 1 fr 1 fr ;
gap : 8 px ;
}
. rule - list {
display : grid ;
gap : 8 px ;
margin : 10 px 0 0 ;
padding : 0 ;
list - style : none ;
}
. rule - item {
display : grid ;
gap : 6 px ;
border : 1 px solid var ( - - line ) ;
border - radius : 8 px ;
background : #ffffff;
padding : 8 px ;
}
. rule - title {
align - items : center ;
display : flex ;
gap : 6 px ;
justify - content : space - between ;
}
. rule - actions { display : flex ; flex - wrap : wrap ; gap : 6 px ; }
. rule - actions button { min - height : 28 px ; padding : 4 px 7 px ; }
2026-05-18 17:11:36 +02:00
. popup {
position : absolute ;
z - index : 4 ;
max - width : 260 px ;
display : none ;
border : 1 px solid var ( - - line ) ;
border - radius : 8 px ;
background : #ffffff;
box - shadow : 0 16 px 40 px rgba ( 23 , 32 , 51 , .14 ) ;
padding : 10 px ;
pointer - events : none ;
}
. popup strong { display : block ; margin - bottom : 4 px ; }
. notice { color : var ( - - warn ) ; padding : 14 px ; }
2026-05-19 00:51:38 +02:00
. canvas - notice {
position : absolute ;
inset : 0 ;
display : grid ;
place - items : center ;
color : var ( - - muted ) ;
padding : 24 px ;
text - align : center ;
}
2026-05-18 17:11:36 +02:00
@media ( max - width : 900 px ) {
. shell { min - height : 760 px ; }
. toolbar { grid - template - columns : 1 fr 1 fr ; }
. main { grid - template - columns : 1 fr ; grid - template - rows : minmax ( 420 px , 1 fr ) 330 px ; }
. side { border - left : 0 ; border - top : 1 px solid var ( - - line ) ; }
2026-05-19 00:51:38 +02:00
. map - controls { top : 8 px ; right : 8 px ; max - width : calc ( 100 % - 16 px ) ; }
2026-05-19 01:16:18 +02:00
. rule - grid { grid - template - columns : 1 fr ; }
2026-05-18 17:11:36 +02:00
}
< / style >
< / head >
< body >
< section class = " shell " >
< div class = " toolbar " >
2026-05-19 00:51:38 +02:00
< div class = " field " >
< span class = " field-label " > Search < button type = " button " class = " help-tip " aria - label = " Search help " data - help - title = " Search " data - help = " Search matches labels, ids, repos, node types, edge types, and descriptions. It hides non-matching nodes and edges without redrawing the layout. " > ? < / button > < / span >
< input id = " search " autocomplete = " off " aria - label = " Search graph " >
< / div >
< div class = " field " >
< span class = " field-label " > Mode < button type = " button " class = " help-tip " aria - label = " Mode help " data - help - title = " Mode " data - help = " Modes are predefined map views. Some modes use the selected item as context; changing modes changes which graph entities are visible, but keeps the layout controls separate. " > ? < / button > < / span >
< select id = " mode-select " aria - label = " Graph mode " > < / select >
< / div >
< div class = " field " >
< span class = " field-label " > Layout < button type = " button " class = " help-tip " aria - label = " Layout help " data - help - title = " Layout " data - help = " Layout redraws the map arrangement. Cose uses relationship strength and repo affinity; circle, grid, concentric, and breadthfirst are simpler alternate arrangements. " > ? < / button > < / span >
< select id = " layout-select " aria - label = " Graph layout " >
2026-05-18 18:31:25 +02:00
< option value = " cose " > Cose < / option >
< option value = " concentric " > Concentric < / option >
< option value = " circle " > Circle < / option >
< option value = " grid " > Grid < / option >
< option value = " breadthfirst " > Breadthfirst < / option >
2026-05-19 00:51:38 +02:00
< / select >
< / div >
< div class = " field " > < span class = " field-label " > Node Types < button type = " button " class = " help-tip " aria - label = " Node types help " data - help - title = " Node Types " data - help = " Nodes are the entities drawn on the map: repositories, services, deployments, servers, capabilities, interfaces, dependencies, bindings, and libraries. This filter hides unchecked node types while preserving the current layout. " > ? < / button > < / span >
2026-05-18 23:58:58 +02:00
< details id = " node-type-menu " class = " filter-menu " >
< summary id = " node-type-summary " > All node types < / summary >
< div id = " node-type-filter " class = " check-list " > < / div >
< / details >
< / div >
2026-05-19 00:51:38 +02:00
< div class = " field " > < span class = " field-label " > Edge Types < button type = " button " class = " help-tip " aria - label = " Edge types help " data - help - title = " Edge Types " data - help = " Edges are relationships between nodes, such as provides, consumes, runs_on, or binds. This filter hides unchecked relationship types while preserving the current layout. " > ? < / button > < / span >
2026-05-18 23:58:58 +02:00
< details id = " edge-type-menu " class = " filter-menu " >
< summary id = " edge-type-summary " > All edge types < / summary >
< div id = " edge-type-filter " class = " check-list " > < / div >
< / details >
< / div >
2026-05-19 00:51:38 +02:00
< div class = " field " >
< span class = " field-label " > Review < button type = " button " class = " help-tip " aria - label = " Review help " data - help - title = " Review " data - help = " Review state applies only to entities that carry review metadata. Accepted means trusted data; candidate means registered or inferred data that still needs context. " > ? < / button > < / span >
< select id = " review-filter " aria - label = " Review state filter " > < option value = " " > Any < / option > < option > accepted < / option > < option > candidate < / option > < / select >
< / div >
< div class = " field " >
< span class = " field-label " > Unresolved < button type = " button " class = " help-tip " aria - label = " Unresolved help " data - help - title = " Unresolved " data - help = " Unresolved marks missing provider bindings or registered repositories without accepted graph snapshots. It only applies to node and edge types that can express those gaps. " > ? < / button > < / span >
< select id = " unresolved-filter " aria - label = " Unresolved filter " > < option value = " " > Any < / option > < option value = " true " > Only < / option > < / select >
< / div >
< div class = " field " >
2026-05-19 01:16:18 +02:00
< span class = " field-label " > Profile < button type = " button " class = " help-tip " aria - label = " Saved views help " data - help - title = " Saved Views " data - help = " Saved views store the current search, mode, layout, label density, filters, rules, and manual visibility overrides in this browser. Copy State creates a shareable URL for the current view. " > ? < / button > < / span >
2026-05-19 00:51:38 +02:00
< select id = " profile-select " aria - label = " Saved view profile " disabled > < option > Unsaved exploration < / option > < / select >
< / div >
2026-05-18 17:11:36 +02:00
< span id = " counts " class = " meta " > < / span >
< / div >
< main class = " main " >
< div class = " canvas-wrap " >
2026-05-19 00:51:38 +02:00
< div id = " graph-canvas " role = " img " aria - label = " Interactive Fabric graph " aria - live = " polite " > < p class = " canvas-notice " > Loading graph . . . < / p > < / div >
2026-05-18 23:58:58 +02:00
< div class = " map-controls " aria - label = " Map navigation controls " >
2026-05-19 00:51:38 +02:00
< label class = " map-control-field " > < span class = " field-label " > Labels < button type = " button " class = " help-tip " aria - label = " Labels help " data - help - title = " Labels " data - help = " Label density changes only text visibility. Auto hides low-priority labels when the map is dense; Key keeps repositories, services, deployments, servers, and issue markers visible. " > ? < / button > < / span >
2026-05-19 00:27:09 +02:00
< select id = " label-select " title = " Control node label density " >
< option value = " auto " > Auto < / option >
< option value = " key " > Key < / option >
< option value = " all " > All < / option >
< option value = " none " > None < / option >
< / select >
< / label >
2026-05-19 00:51:38 +02:00
< button type = " button " data - action = " fit " title = " Fit graph to view " data - help - title = " Fit " data - help = " Fit pans and zooms the current visible graph into the map viewport. It does not change filters, labels, or layout. " > Fit < / button >
< button type = " button " data - action = " focus " title = " Focus selected neighborhood " data - help - title = " Focus " data - help = " Focus narrows the map to the selected entity and its nearby context. It changes visibility, not the underlying registry data. " > Focus < / button >
< button type = " button " data - action = " clear " class = " primary " title = " Reset map controls " data - help - title = " Reset " data - help = " Reset clears search, mode, node and edge type filters, label density, unresolved/review filters, focus, and manual overrides. " > Reset < / button >
2026-05-18 23:58:58 +02:00
< / div >
2026-05-19 00:16:57 +02:00
< div id = " selection-anchor " class = " selection-anchor " aria - hidden = " true " >
< span id = " selection-anchor-label " class = " selection-anchor-label " > < / span >
< / div >
2026-05-18 17:11:36 +02:00
< div id = " popup " class = " popup " > < / div >
2026-05-19 00:51:38 +02:00
< div id = " help-popup " class = " help-popup " role = " tooltip " > < / div >
2026-05-18 17:11:36 +02:00
< / div >
< aside class = " side " >
< section class = " section " >
< h1 id = " detail-title " > Fabric Map < / h1 >
< p id = " detail-summary " class = " meta " > No selection < / p >
< div id = " detail-pills " > < / div >
< ul id = " detail-list " class = " detail-list " > < / ul >
< / section >
2026-05-18 18:36:15 +02:00
< section class = " section " >
< p id = " orientation-title " class = " meta " > Select a service , interface , dependency , or registered - only repo . < / p >
2026-05-19 01:46:23 +02:00
< ul id = " orientation-list " class = " detail-list orientation-list " > < / ul >
< div id = " orientation-actions " class = " button-row " > < / div >
2026-05-18 18:36:15 +02:00
< / section >
2026-05-18 17:11:36 +02:00
< section class = " section " >
< div class = " button-row " >
2026-05-19 00:51:38 +02:00
< button type = " button " data - override = " show " data - help - title = " Show " data - help = " Show pins the selected entity visible even when other filters would hide it. It does not redraw the layout. " > Show < / button >
< button type = " button " data - override = " blur " data - help - title = " Blur " data - help = " Blur keeps the selected entity in place but reduces emphasis. It is useful for context that should stay visible without competing for attention. " > Blur < / button >
< button type = " button " data - override = " hide " data - help - title = " Hide " data - help = " Hide makes the selected entity invisible while preserving layout context. Remove is reserved for the upcoming rule panel and will redraw the graph without the entity. " > Hide < / button >
< button type = " button " data - action = " reset-overrides " data - help - title = " Clear Overrides " data - help = " Clear Overrides removes manual Show, Blur, and Hide choices. Filters, mode, layout, and label density stay as they are. " > Clear Overrides < / button >
2026-05-18 17:11:36 +02:00
< / div >
< / section >
2026-05-19 01:16:18 +02:00
< section class = " section " >
< details id = " rule-panel " class = " rule-panel " >
< summary > < span class = " field-label " > Rules < button type = " button " class = " help-tip " aria - label = " Rule builder help " data - help - title = " Rule Builder " data - help = " Rules select nodes or edges by type and available attributes, then apply a modifier. Hide preserves the current layout; Remove redraws the map without matching entities. " > ? < / button > < / span > < / summary >
< div class = " rule-editor " >
< div class = " rule-grid " >
< label > Target
< select id = " rule-target " >
< option value = " node " > Nodes < / option >
< option value = " edge " > Edges < / option >
< / select >
< / label >
< label > Type
< select id = " rule-type " > < / select >
< / label >
< label > Attribute
< select id = " rule-attribute " > < / select >
< / label >
< label > Value
< select id = " rule-value " > < / select >
< / label >
< label > Modifier
< select id = " rule-action " >
< option value = " highlight " > Highlight < / option >
< option value = " show " > Show < / option >
< option value = " blur " > Blur < / option >
< option value = " hide " > Hide < / option >
< option value = " remove " > Remove and redraw < / option >
< / select >
< / label >
< / div >
< p id = " rule-context " class = " meta " > Rules are applied top to bottom ; later matching rules refine earlier ones . < / p >
< div class = " button-row " >
< button type = " button " data - rule - action = " save " class = " primary " > Add Rule < / button >
< button type = " button " data - rule - action = " cancel " > Cancel Edit < / button >
< button type = " button " data - rule - action = " reset " > Reset Rules < / button >
< / div >
< ul id = " rule-list " class = " rule-list " > < / ul >
< / div >
< / details >
< / section >
2026-05-18 18:31:25 +02:00
< section class = " section " >
< p id = " profile-summary " class = " meta " > Profile persistence unavailable for this host . < / p >
2026-05-19 00:51:38 +02:00
< div class = " field " >
< span class = " field-label " > Profile Name < button type = " button " class = " help-tip " aria - label = " Profile name help " data - help - title = " Profile Name " data - help = " Profile Name labels a saved browser-local map view. It does not rename graph entities or registry data. " > ? < / button > < / span >
< input id = " profile-name " autocomplete = " off " aria - label = " Profile name " disabled >
< / div >
2026-05-18 18:31:25 +02:00
< div class = " button-row " >
2026-05-19 01:16:18 +02:00
< button type = " button " data - profile - action = " save " data - help - title = " Save " data - help = " Save stores the current browser-local view, including filters, rules, layout, label density, and manual overrides. " disabled > Save < / button >
2026-05-19 00:51:38 +02:00
< button type = " button " data - profile - action = " duplicate " data - help - title = " Duplicate " data - help = " Duplicate creates a new saved view from the currently loaded profile and current map state. " disabled > Duplicate < / button >
< button type = " button " data - profile - action = " delete " data - help - title = " Delete " data - help = " Delete removes the selected browser-local saved view. It does not delete graph data. " disabled > Delete < / button >
2026-05-19 01:16:18 +02:00
< button type = " button " data - profile - action = " copy " data - help - title = " Copy State " data - help = " Copy State copies a URL that can restore this map state, including filters, rules, and manual overrides. " disabled > Copy State < / button >
2026-05-18 18:31:25 +02:00
< / div >
< / section >
2026-05-18 17:11:36 +02:00
< section class = " section " >
2026-05-19 00:51:38 +02:00
< p class = " meta " > < span id = " hidden-summary " > Hidden 0 < / span > < button type = " button " class = " help-tip " aria - label = " Hidden entities help " data - help - title = " Hidden Entities " data - help = " Hidden counts entities currently not shown because of filters, mode, or manual Hide overrides. Hidden keeps layout context; Remove will redraw without the entity in the rule panel. " > ? < / button > < / p >
2026-05-18 17:11:36 +02:00
< div id = " legend " > < / div >
< / section >
< / aside >
< / main >
< / section >
< script src = " https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js " > < / script >
< script >
( ( ) = > {
const manifestUrl = " /exports/graph-explorer/manifest " ;
const graphUrl = " /exports/graph-explorer " ;
const canvas = document . getElementById ( " graph-canvas " ) ;
const popup = document . getElementById ( " popup " ) ;
2026-05-19 00:51:38 +02:00
const helpPopup = document . getElementById ( " help-popup " ) ;
2026-05-19 00:16:57 +02:00
const selectionAnchor = document . getElementById ( " selection-anchor " ) ;
const selectionAnchorLabel = document . getElementById ( " selection-anchor-label " ) ;
2026-05-18 17:11:36 +02:00
const searchInput = document . getElementById ( " search " ) ;
2026-05-18 18:31:25 +02:00
const modeSelect = document . getElementById ( " mode-select " ) ;
const layoutSelect = document . getElementById ( " layout-select " ) ;
2026-05-19 00:27:09 +02:00
const labelSelect = document . getElementById ( " label-select " ) ;
2026-05-18 23:58:58 +02:00
const nodeTypeFilter = document . getElementById ( " node-type-filter " ) ;
const nodeTypeSummary = document . getElementById ( " node-type-summary " ) ;
const edgeTypeFilter = document . getElementById ( " edge-type-filter " ) ;
const edgeTypeSummary = document . getElementById ( " edge-type-summary " ) ;
2026-05-18 17:11:36 +02:00
const reviewFilter = document . getElementById ( " review-filter " ) ;
const unresolvedFilter = document . getElementById ( " unresolved-filter " ) ;
2026-05-19 01:16:18 +02:00
const ruleTarget = document . getElementById ( " rule-target " ) ;
const ruleType = document . getElementById ( " rule-type " ) ;
const ruleAttribute = document . getElementById ( " rule-attribute " ) ;
const ruleValue = document . getElementById ( " rule-value " ) ;
const ruleAction = document . getElementById ( " rule-action " ) ;
const ruleContext = document . getElementById ( " rule-context " ) ;
const ruleList = document . getElementById ( " rule-list " ) ;
2026-05-18 18:31:25 +02:00
const profileSelect = document . getElementById ( " profile-select " ) ;
2026-05-18 20:14:07 +02:00
const profileNameInput = document . getElementById ( " profile-name " ) ;
2026-05-18 18:31:25 +02:00
const profileSummary = document . getElementById ( " profile-summary " ) ;
2026-05-18 17:11:36 +02:00
const counts = document . getElementById ( " counts " ) ;
const hiddenSummary = document . getElementById ( " hidden-summary " ) ;
const detailTitle = document . getElementById ( " detail-title " ) ;
const detailSummary = document . getElementById ( " detail-summary " ) ;
const detailPills = document . getElementById ( " detail-pills " ) ;
const detailList = document . getElementById ( " detail-list " ) ;
2026-05-18 18:36:15 +02:00
const orientationTitle = document . getElementById ( " orientation-title " ) ;
const orientationList = document . getElementById ( " orientation-list " ) ;
2026-05-19 01:46:23 +02:00
const orientationActions = document . getElementById ( " orientation-actions " ) ;
2026-05-18 17:11:36 +02:00
const legend = document . getElementById ( " legend " ) ;
2026-05-18 20:14:07 +02:00
const profileStorageKey = " railiance.fabric.graphExplorer.profiles " ;
2026-05-18 17:11:36 +02:00
let cy = null ;
let selected = null ;
2026-05-19 00:16:57 +02:00
let selectedAnchor = null ;
2026-05-18 17:11:36 +02:00
let focusSet = null ;
let manualOverrides = { } ;
let layerColors = { } ;
2026-05-18 23:58:58 +02:00
let nodeTypeLabels = { } ;
let allNodeTypes = [ ] ;
let allEdgeTypes = [ ] ;
2026-05-18 18:31:25 +02:00
let activeMode = " full " ;
2026-05-19 00:27:09 +02:00
let activeLabelMode = " auto " ;
2026-05-18 20:14:07 +02:00
let profilePersistence = " none " ;
let profiles = [ ] ;
let currentProfileId = " " ;
2026-05-19 01:16:18 +02:00
let filterRules = [ ] ;
let editingRuleId = " " ;
2026-05-19 01:46:23 +02:00
let orientationContext = null ;
2026-05-18 17:11:36 +02:00
const escapeHtml = ( value ) = > String ( value ? ? " " )
. replaceAll ( " & " , " & " ) . replaceAll ( " < " , " < " )
. replaceAll ( " > " , " > " ) . replaceAll ( ' " ' , " " " ) ;
2026-05-19 00:51:38 +02:00
const showHelp = ( target ) = > {
if ( ! helpPopup | | ! target ? . dataset ? . help ) return ;
const bounds = target . getBoundingClientRect ( ) ;
const title = target . dataset . helpTitle | | target . textContent | | " Help " ;
helpPopup . innerHTML = ` < strong > $ { escapeHtml ( title ) } < / strong > < p > $ { escapeHtml ( target . dataset . help ) } < / p > ` ;
helpPopup . style . display = " block " ;
const width = Math . min ( helpPopup . offsetWidth | | 300 , 300 ) ;
const left = Math . min ( Math . max ( 12 , bounds . left ) , window . innerWidth - width - 12 ) ;
const top = bounds . bottom + 8 < window . innerHeight - 80
? bounds . bottom + 8
: Math . max ( 12 , bounds . top - helpPopup . offsetHeight - 8 ) ;
helpPopup . style . left = ` $ { left } px ` ;
helpPopup . style . top = ` $ { top } px ` ;
} ;
const hideHelp = ( ) = > {
if ( ! helpPopup ) return ;
helpPopup . style . display = " none " ;
} ;
2026-05-18 17:11:36 +02:00
const elementText = ( data ) = > [
data . id , data . stableKey , data . label , data . name , data . description ,
data . repo , data . domain , data . kind , data . layer , data . edgeType
] . join ( " " ) . toLowerCase ( ) ;
2026-05-18 20:14:07 +02:00
const overrideKey = ( element ) = > element . data ( " stableKey " ) | | element . id ( ) ;
const manualOverrideFor = ( element ) = >
manualOverrides [ overrideKey ( element ) ] | | manualOverrides [ element . id ( ) ] ;
const hasManualOverrides = ( ) = > Object . keys ( manualOverrides ) . length > 0 ;
const supportsLocalProfiles = ( ) = > profilePersistence == = " local " ;
2026-05-18 23:58:58 +02:00
const humanize = ( value ) = > String ( value | | " " )
. replaceAll ( " _ " , " " )
. replaceAll ( " : " , " : " )
. replace ( / \\b \\w / g , ( letter ) = > letter . toUpperCase ( ) ) ;
const parseListParam = ( value ) = > String ( value | | " " )
. split ( " , " )
. map ( ( item ) = > item . trim ( ) )
. filter ( Boolean ) ;
const checkedValues = ( container ) = > new Set (
Array . from ( container . querySelectorAll ( " input[type= ' checkbox ' ] " ) )
. filter ( ( input ) = > input . checked )
. map ( ( input ) = > input . value )
) ;
const setCheckedValues = ( container , values = null ) = > {
const explicit = Array . isArray ( values ) ;
const selectedValues = new Set ( values | | [ ] ) ;
container . querySelectorAll ( " input[type= ' checkbox ' ] " ) . forEach ( ( input ) = > {
input . checked = explicit ? selectedValues . has ( input . value ) : true ;
} ) ;
} ;
const selectedNodeTypes = ( ) = > checkedValues ( nodeTypeFilter ) ;
const selectedEdgeTypes = ( ) = > checkedValues ( edgeTypeFilter ) ;
const summarizeSelection = ( selected , allValues , labels , noun ) = > {
if ( selected . size == = 0 ) return ` No $ { noun } ` ;
if ( selected . size == = allValues . length ) return ` All $ { noun } ` ;
const names = allValues
. filter ( ( value ) = > selected . has ( value ) )
. map ( ( value ) = > labels [ value ] | | humanize ( value ) ) ;
return names . length < = 2 ? names . join ( " , " ) : ` $ { names . length } $ { noun } ` ;
} ;
const syncFilterSummaries = ( ) = > {
nodeTypeSummary . textContent = summarizeSelection (
selectedNodeTypes ( ) , allNodeTypes , nodeTypeLabels , " node types "
) ;
edgeTypeSummary . textContent = summarizeSelection (
selectedEdgeTypes ( ) , allEdgeTypes , { } , " edge types "
) ;
} ;
const renderChecklist = ( container , values , labels , name ) = > {
container . innerHTML = values . map ( ( value ) = > {
const label = labels [ value ] | | humanize ( value ) ;
return ` < label > < input type = " checkbox " name = " $ { escapeHtml(name)} " value = " $ { escapeHtml(value)} " checked > $ { escapeHtml ( label ) } < / label > ` ;
} ) . join ( " " ) ;
} ;
2026-05-19 01:16:18 +02:00
const ruleActionLabels = {
show : " Show " ,
hide : " Hide " ,
blur : " Blur " ,
highlight : " Highlight " ,
remove : " Remove and redraw " ,
} ;
const ruleAttributeLabels = {
any : " Any attribute " ,
repo : " Repo " ,
kind : " Kind " ,
reviewState : " Review state " ,
unresolved : " Unresolved " ,
lifecycle : " Lifecycle " ,
strength : " Strength " ,
sameRepo : " Same repo " ,
layoutAffinity : " Layout affinity " ,
} ;
const ruleAttributeCandidates = {
node : [ " any " , " repo " , " kind " , " reviewState " , " unresolved " , " lifecycle " ] ,
edge : [ " any " , " repo " , " kind " , " reviewState " , " unresolved " , " strength " , " sameRepo " , " layoutAffinity " ] ,
} ;
const renderOptions = ( select , options , current = " " ) = > {
select . innerHTML = options . map ( ( option ) = >
` < option value = " $ { escapeHtml(option.value)} " > $ { escapeHtml ( option . label ) } < / option > `
) . join ( " " ) ;
if ( options . some ( ( option ) = > option . value == = current ) ) select . value = current ;
} ;
const ruleElementType = ( element , target ) = >
target == = " edge " ? element . data ( " edgeType " ) : element . data ( " layer " ) ;
const currentRuleElements = ( ) = > {
if ( ! cy ) return [ ] ;
const target = ruleTarget . value | | " node " ;
const type = ruleType . value | | " " ;
const elements = target == = " edge " ? cy . edges ( ) : cy . nodes ( ) ;
return elements . filter ( ( element ) = > ! type | | ruleElementType ( element , target ) == = type ) . toArray ( ) ;
} ;
const valuesForRuleAttribute = ( attribute ) = > {
if ( attribute == = " any " ) return [ { value : " " , label : " Any " } ] ;
const values = Array . from ( new Set ( currentRuleElements ( )
. map ( ( element ) = > element . data ( attribute ) )
. filter ( ( value ) = > value != = undefined & & value != = null & & value != = " " )
. map ( ( value ) = > String ( value ) )
) ) . sort ( ( left , right ) = > left . localeCompare ( right ) ) ;
return values . length
? values . map ( ( value ) = > ( { value , label : humanize ( value ) } ) )
: [ { value : " " , label : " No values " } ] ;
} ;
const availableRuleAttributes = ( ) = > {
const elements = currentRuleElements ( ) ;
return ( ruleAttributeCandidates [ ruleTarget . value | | " node " ] | | [ " any " ] )
. filter ( ( attribute ) = > attribute == = " any " | | elements . some ( ( element ) = > {
const value = element . data ( attribute ) ;
return value != = undefined & & value != = null & & value != = " " ;
} ) )
. map ( ( attribute ) = > ( { value : attribute , label : ruleAttributeLabels [ attribute ] | | humanize ( attribute ) } ) ) ;
} ;
const refreshRuleBuilder = ( state = { } ) = > {
const target = state . target | | ruleTarget . value | | " node " ;
ruleTarget . value = target ;
const types = target == = " edge " ? allEdgeTypes : allNodeTypes ;
const labels = target == = " edge " ? { } : nodeTypeLabels ;
renderOptions ( ruleType , [
{ value : " " , label : target == = " edge " ? " All edge types " : " All node types " } ,
. . . types . map ( ( type ) = > ( { value : type , label : labels [ type ] | | humanize ( type ) } ) ) ,
] , state . type ? ? ruleType . value ) ;
renderOptions ( ruleAttribute , availableRuleAttributes ( ) , state . attribute ? ? ruleAttribute . value ) ;
renderOptions ( ruleValue , valuesForRuleAttribute ( ruleAttribute . value | | " any " ) , state . value ? ? ruleValue . value ) ;
if ( state . action & & ruleActionLabels [ state . action ] ) ruleAction . value = state . action ;
ruleValue . disabled = ( ruleAttribute . value | | " any " ) == = " any " | | ruleValue . options . length == = 0 ;
const attributeNames = Array . from ( ruleAttribute . options ) . map ( ( option ) = > option . textContent ) . join ( " , " ) ;
ruleContext . textContent = attributeNames
? ` Available here : $ { attributeNames } . Rules are applied top to bottom ; later matches refine earlier ones . `
: " No attributes are available for this type yet. " ;
} ;
const normalizeRules = ( rules ) = > Array . isArray ( rules )
? rules
. filter ( ( rule ) = > rule & & ( rule . target == = " node " | | rule . target == = " edge " ) & & ruleActionLabels [ rule . action ] )
. map ( ( rule , index ) = > ( {
id : String ( rule . id | | ` rule - $ { Date . now ( ) . toString ( 36 ) } - $ { index } ` ) ,
target : rule . target ,
type : String ( rule . type | | " " ) ,
attribute : String ( rule . attribute | | " any " ) ,
value : String ( rule . value | | " " ) ,
action : rule . action ,
} ) )
: [ ] ;
const ruleDescription = ( rule ) = > {
const target = rule . target == = " edge " ? " edges " : " nodes " ;
const typeLabel = rule . type
? ( rule . target == = " edge " ? humanize ( rule . type ) : nodeTypeLabels [ rule . type ] | | humanize ( rule . type ) )
: ` all $ { target } ` ;
const attribute = rule . attribute & & rule . attribute != = " any "
? ` $ { ruleAttributeLabels [ rule . attribute ] | | humanize ( rule . attribute ) } = $ { humanize ( rule . value ) } `
: " any attribute " ;
return ` $ { ruleActionLabels [ rule . action ] } $ { typeLabel } where $ { attribute } ` ;
} ;
const renderRules = ( ) = > {
const saveButton = document . querySelector ( " [data-rule-action= ' save ' ] " ) ;
const cancelButton = document . querySelector ( " [data-rule-action= ' cancel ' ] " ) ;
saveButton . textContent = editingRuleId ? " Update Rule " : " Add Rule " ;
cancelButton . disabled = ! editingRuleId ;
ruleList . innerHTML = filterRules . length
? filterRules . map ( ( rule , index ) = > `
< li class = " rule-item " data - rule - id = " $ { escapeHtml(rule.id)} " >
< div class = " rule-title " > < strong > $ { escapeHtml ( ruleDescription ( rule ) ) } < / strong > < span class = " meta " > $ { index + 1 } < / span > < / div >
< div class = " rule-actions " >
< button type = " button " data - rule - list - action = " up " $ { index == = 0 ? " disabled " : " " } > Up < / button >
< button type = " button " data - rule - list - action = " down " $ { index == = filterRules . length - 1 ? " disabled " : " " } > Down < / button >
< button type = " button " data - rule - list - action = " edit " > Edit < / button >
< button type = " button " data - rule - list - action = " delete " > Delete < / button >
< / div >
< / li >
` ) . join ( " " )
: ' <li class= " meta " >No rules yet.</li> ' ;
} ;
const matchesRule = ( element , rule ) = > {
if ( rule . target == = " node " & & ! element . isNode ( ) ) return false ;
if ( rule . target == = " edge " & & ! element . isEdge ( ) ) return false ;
if ( rule . type & & ruleElementType ( element , rule . target ) != = rule . type ) return false ;
if ( ! rule . attribute | | rule . attribute == = " any " ) return true ;
const value = element . data ( rule . attribute ) ;
return value != = undefined & & value != = null & & String ( value ) == = String ( rule . value ) ;
} ;
const ruleActionFor = ( element ) = > {
let action = " " ;
filterRules . forEach ( ( rule ) = > {
if ( matchesRule ( element , rule ) ) action = rule . action ;
} ) ;
return action ;
} ;
const ruleRemovalSignature = ( ) = > cy
? cy . elements ( )
. filter ( ( element ) = > element . data ( " displayState " ) == = " remove " )
. map ( ( element ) = > element . id ( ) )
. sort ( )
. join ( " | " )
: " " ;
2026-05-19 00:16:57 +02:00
const isFinitePoint = ( position ) = >
position & & Number . isFinite ( position . x ) & & Number . isFinite ( position . y ) ;
const elementLabel = ( element ) = > {
if ( ! element ) return " " ;
const data = element . data ( ) ;
return data . name | | data . label | | data . edgeType | | data . id ;
} ;
2026-05-19 01:46:23 +02:00
const nodeById = ( id ) = > {
if ( ! cy | | ! id ) return null ;
const node = cy . getElementById ( id ) ;
return node & & node . length > 0 ? node : null ;
} ;
const nodeName = ( id ) = > {
const node = nodeById ( id ) ;
return node ? elementLabel ( node ) : id ;
} ;
const addContextElement = ( ids , element ) = > {
if ( ! element | | element . length == = 0 ) return ;
ids . add ( element . id ( ) ) ;
} ;
const addContextNode = ( ids , id ) = > {
const node = nodeById ( id ) ;
if ( node ) ids . add ( node . id ( ) ) ;
} ;
const addContextEdge = ( ids , edge ) = > {
addContextElement ( ids , edge ) ;
if ( ! edge | | edge . length == = 0 ) return ;
addContextNode ( ids , edge . data ( " source " ) ) ;
addContextNode ( ids , edge . data ( " target " ) ) ;
} ;
const edgeCollection = ( source = " " , target = " " , type = " " ) = > cy ? cy . edges ( ) . filter ( ( edge ) = >
( ! source | | edge . data ( " source " ) == = source ) & &
( ! target | | edge . data ( " target " ) == = target ) & &
( ! type | | edge . data ( " edgeType " ) == = type )
) : [ ] ;
const collectionArray = ( collection ) = > Array . from ( collection | | [ ] ) ;
const orientationStateClass = ( state ) = >
state == = " warning " ? " orientation-warning " : state == = " good " ? " orientation-good " : " " ;
2026-05-19 00:16:57 +02:00
const renderedElementPosition = ( element ) = > {
if ( ! element | | ! cy ) return null ;
if ( element . isNode & & element . isNode ( ) ) return element . renderedPosition ( ) ;
if ( element . isEdge & & element . isEdge ( ) ) {
if ( typeof element . renderedMidpoint == = " function " ) {
const midpoint = element . renderedMidpoint ( ) ;
if ( isFinitePoint ( midpoint ) ) return midpoint ;
}
const source = cy . getElementById ( element . data ( " source " ) ) ;
const target = cy . getElementById ( element . data ( " target " ) ) ;
if ( source . length > 0 & & target . length > 0 ) {
const sourcePosition = source . renderedPosition ( ) ;
const targetPosition = target . renderedPosition ( ) ;
if ( isFinitePoint ( sourcePosition ) & & isFinitePoint ( targetPosition ) ) {
return {
x : ( sourcePosition . x + targetPosition . x ) / 2 ,
y : ( sourcePosition . y + targetPosition . y ) / 2 ,
} ;
}
}
}
return null ;
} ;
const updateSelectionAnchor = ( ) = > {
if ( ! selected | | ! selectionAnchor ) {
selectedAnchor = null ;
selectionAnchor . style . display = " none " ;
return ;
}
const position = renderedElementPosition ( selected ) ;
if ( isFinitePoint ( position ) ) {
selectedAnchor = { x : position . x , y : position . y , label : elementLabel ( selected ) } ;
}
if ( ! selectedAnchor ) {
selectionAnchor . style . display = " none " ;
return ;
}
selectionAnchor . style . left = ` $ { selectedAnchor . x } px ` ;
selectionAnchor . style . top = ` $ { selectedAnchor . y } px ` ;
selectionAnchorLabel . textContent = selectedAnchor . label ;
selectionAnchor . style . display = " block " ;
} ;
2026-05-19 00:27:09 +02:00
const isKeyLabelNode = ( node ) = > {
const layer = node . data ( " layer " ) ;
return (
[ " repository " , " server " , " deployment " , " service " ] . includes ( layer ) | |
node . data ( " unresolved " ) == = true | |
node . data ( " lifecycle " ) == = " registered-only "
) ;
} ;
const updateLabelVisibility = ( ) = > {
if ( ! cy ) return ;
const visibleNodes = cy . nodes ( ) . filter ( ( node ) = > node . style ( " display " ) != = " none " ) ;
const mode = activeLabelMode == = " auto " & & visibleNodes . length > 70 ? " key " : activeLabelMode ;
cy . nodes ( ) . forEach ( ( node ) = > {
let showLabel = mode == = " all " | | mode == = " auto " ;
if ( mode == = " key " ) showLabel = isKeyLabelNode ( node ) ;
if ( mode == = " none " ) showLabel = false ;
if ( selected & & node . id ( ) == = selected . id ( ) ) showLabel = false ;
node . toggleClass ( " label-hidden " , ! showLabel ) ;
} ) ;
} ;
2026-05-18 20:14:07 +02:00
const currentViewState = ( ) = > ( {
search : searchInput . value ,
mode : modeSelect . value | | " full " ,
layout : layoutSelect . value | | " cose " ,
2026-05-19 00:27:09 +02:00
labelMode : labelSelect . value | | " auto " ,
2026-05-18 23:58:58 +02:00
nodeTypes : Array . from ( selectedNodeTypes ( ) ) ,
edgeTypes : Array . from ( selectedEdgeTypes ( ) ) ,
2026-05-18 20:14:07 +02:00
review : reviewFilter . value ,
unresolved : unresolvedFilter . value ,
2026-05-19 01:16:18 +02:00
rules : filterRules . map ( ( rule ) = > ( { . . . rule } ) ) ,
2026-05-18 20:14:07 +02:00
manualOverrides : { . . . manualOverrides } ,
} ) ;
const encodeStateBlob = ( state ) = > {
const bytes = new TextEncoder ( ) . encode ( JSON . stringify ( state ) ) ;
let binary = " " ;
bytes . forEach ( ( byte ) = > { binary + = String . fromCharCode ( byte ) ; } ) ;
return btoa ( binary ) ;
} ;
const decodeStateBlob = ( blob ) = > {
const bytes = Uint8Array . from ( atob ( blob ) , ( char ) = > char . charCodeAt ( 0 ) ) ;
return JSON . parse ( new TextDecoder ( ) . decode ( bytes ) ) ;
} ;
const readUrlState = ( ) = > {
const params = new URLSearchParams ( window . location . search ) ;
const state = { } ;
if ( params . has ( " search " ) ) state . search = params . get ( " search " ) | | " " ;
if ( params . has ( " mode " ) ) state . mode = params . get ( " mode " ) | | " " ;
if ( params . has ( " layout " ) ) state . layout = params . get ( " layout " ) | | " " ;
2026-05-19 00:27:09 +02:00
if ( params . has ( " labelMode " ) ) state . labelMode = params . get ( " labelMode " ) | | " " ;
2026-05-18 23:58:58 +02:00
if ( params . has ( " nodeTypes " ) ) state . nodeTypes = parseListParam ( params . get ( " nodeTypes " ) ) ;
if ( params . has ( " edgeTypes " ) ) state . edgeTypes = parseListParam ( params . get ( " edgeTypes " ) ) ;
if ( params . has ( " layer " ) ) state . nodeTypes = parseListParam ( params . get ( " layer " ) ) ;
2026-05-18 20:14:07 +02:00
if ( params . has ( " review " ) ) state . review = params . get ( " review " ) | | " " ;
if ( params . has ( " unresolved " ) ) state . unresolved = params . get ( " unresolved " ) | | " " ;
if ( params . has ( " profile " ) ) state . profile = params . get ( " profile " ) | | " " ;
if ( params . has ( " state " ) ) {
try {
Object . assign ( state , decodeStateBlob ( params . get ( " state " ) | | " " ) ) ;
} catch ( error ) {
profileSummary . textContent = ` Could not load copied map state : $ { error . message } ` ;
}
}
return state ;
} ;
const viewUrl = ( includeStateBlob = false ) = > {
const params = new URLSearchParams ( ) ;
const state = currentViewState ( ) ;
if ( state . search ) params . set ( " search " , state . search ) ;
if ( state . mode & & state . mode != = " full " ) params . set ( " mode " , state . mode ) ;
if ( state . layout & & state . layout != = " cose " ) params . set ( " layout " , state . layout ) ;
2026-05-19 00:27:09 +02:00
if ( state . labelMode & & state . labelMode != = " auto " ) params . set ( " labelMode " , state . labelMode ) ;
2026-05-18 23:58:58 +02:00
if ( state . nodeTypes . length != = allNodeTypes . length ) params . set ( " nodeTypes " , state . nodeTypes . join ( " , " ) ) ;
if ( state . edgeTypes . length != = allEdgeTypes . length ) params . set ( " edgeTypes " , state . edgeTypes . join ( " , " ) ) ;
2026-05-18 20:14:07 +02:00
if ( state . review ) params . set ( " review " , state . review ) ;
if ( state . unresolved ) params . set ( " unresolved " , state . unresolved ) ;
if ( currentProfileId ) params . set ( " profile " , currentProfileId ) ;
2026-05-19 01:16:18 +02:00
if ( includeStateBlob | | hasManualOverrides ( ) | | filterRules . length > 0 ) {
params . set ( " state " , encodeStateBlob ( state ) ) ;
}
2026-05-18 20:14:07 +02:00
const query = params . toString ( ) ;
return ` $ { window . location . pathname } $ { query ? ` ? $ { query } ` : " " } $ { window . location . hash } ` ;
} ;
const updateUrlState = ( ) = > {
if ( ! window . history | | ! window . history . replaceState ) return ;
window . history . replaceState ( { } , " " , viewUrl ( false ) ) ;
} ;
const optionExists = ( select , value ) = >
Array . from ( select . options ) . some ( ( option ) = > option . value == = value ) ;
const applyViewState = ( state , options = { } ) = > {
if ( " search " in state ) searchInput . value = state . search | | " " ;
if ( " mode " in state ) modeSelect . value = optionExists ( modeSelect , state . mode ) ? state . mode : " full " ;
if ( " layout " in state ) layoutSelect . value = optionExists ( layoutSelect , state . layout ) ? state . layout : " cose " ;
2026-05-19 00:27:09 +02:00
if ( " labelMode " in state ) labelSelect . value = optionExists ( labelSelect , state . labelMode ) ? state . labelMode : " auto " ;
2026-05-18 23:58:58 +02:00
if ( " nodeTypes " in state ) setCheckedValues ( nodeTypeFilter , ( state . nodeTypes | | [ ] ) . filter ( ( value ) = > allNodeTypes . includes ( value ) ) ) ;
if ( " layer " in state ) setCheckedValues ( nodeTypeFilter , allNodeTypes . includes ( state . layer ) ? [ state . layer ] : null ) ;
if ( " edgeTypes " in state ) setCheckedValues ( edgeTypeFilter , ( state . edgeTypes | | [ ] ) . filter ( ( value ) = > allEdgeTypes . includes ( value ) ) ) ;
2026-05-18 20:14:07 +02:00
if ( " review " in state ) reviewFilter . value = optionExists ( reviewFilter , state . review ) ? state . review : " " ;
if ( " unresolved " in state ) unresolvedFilter . value = optionExists ( unresolvedFilter , state . unresolved ) ? state . unresolved : " " ;
if ( " manualOverrides " in state & & state . manualOverrides & & typeof state . manualOverrides == = " object " ) {
manualOverrides = { . . . state . manualOverrides } ;
}
2026-05-19 01:16:18 +02:00
if ( " rules " in state ) {
filterRules = normalizeRules ( state . rules ) ;
editingRuleId = " " ;
refreshRuleBuilder ( ) ;
renderRules ( ) ;
}
2026-05-18 20:14:07 +02:00
activeMode = modeSelect . value | | " full " ;
2026-05-19 00:27:09 +02:00
activeLabelMode = labelSelect . value | | " auto " ;
2026-05-18 20:14:07 +02:00
focusSet = null ;
2026-05-18 23:58:58 +02:00
syncFilterSummaries ( ) ;
2026-05-18 20:14:07 +02:00
applyFilters ( ) ;
if ( ! options . skipLayout ) runLayout ( ) ;
if ( ! options . skipUrl ) updateUrlState ( ) ;
} ;
const loadProfiles = ( ) = > {
if ( ! supportsLocalProfiles ( ) ) return [ ] ;
try {
const parsed = JSON . parse ( window . localStorage . getItem ( profileStorageKey ) | | " [] " ) ;
return Array . isArray ( parsed )
? parsed . filter ( ( profile ) = > profile & & typeof profile . id == = " string " )
: [ ] ;
} catch {
return [ ] ;
}
} ;
const persistProfiles = ( ) = > {
if ( ! supportsLocalProfiles ( ) ) return ;
window . localStorage . setItem ( profileStorageKey , JSON . stringify ( profiles ) ) ;
} ;
const selectedProfile = ( ) = > profiles . find ( ( profile ) = > profile . id == = currentProfileId ) | | null ;
2026-05-19 01:46:23 +02:00
const viewStateSummary = ( ) = > {
const parts = [ ] ;
if ( searchInput . value . trim ( ) ) parts . push ( " search " ) ;
if ( ( modeSelect . value | | " full " ) != = " full " ) parts . push ( modeSelect . options [ modeSelect . selectedIndex ] ? . textContent | | " mode " ) ;
if ( ( labelSelect . value | | " auto " ) != = " auto " ) parts . push ( ` $ { labelSelect . value } labels ` ) ;
if ( selectedNodeTypes ( ) . size != = allNodeTypes . length ) parts . push ( " node filter " ) ;
if ( selectedEdgeTypes ( ) . size != = allEdgeTypes . length ) parts . push ( " edge filter " ) ;
if ( reviewFilter . value ) parts . push ( ` $ { reviewFilter . value } review ` ) ;
if ( unresolvedFilter . value ) parts . push ( " unresolved only " ) ;
if ( filterRules . length ) parts . push ( ` $ { filterRules . length } rule $ { filterRules . length == = 1 ? " " : " s " } ` ) ;
const overrideCount = Object . keys ( manualOverrides ) . length ;
if ( overrideCount ) parts . push ( ` $ { overrideCount } override $ { overrideCount == = 1 ? " " : " s " } ` ) ;
return parts . length ? parts . join ( " , " ) : " no filters " ;
} ;
2026-05-18 20:14:07 +02:00
const updateProfileSummary = ( message = " " ) = > {
if ( ! supportsLocalProfiles ( ) ) {
profileSummary . textContent = " Profile persistence unavailable for this host. " ;
return ;
}
const profile = selectedProfile ( ) ;
const prefix = profile ? ` Loaded " $ {profile.name} " . ` : " Unsaved exploration. " ;
2026-05-19 01:46:23 +02:00
profileSummary . textContent = message | | ` $ { prefix } Current state : $ { viewStateSummary ( ) } . $ { profiles . length } saved view $ { profiles . length == = 1 ? " " : " s " } . ` ;
2026-05-18 20:14:07 +02:00
} ;
const updateProfileControls = ( ) = > {
const enabled = supportsLocalProfiles ( ) ;
profileSelect . disabled = ! enabled ;
profileNameInput . disabled = ! enabled ;
document . querySelectorAll ( " [data-profile-action] " ) . forEach ( ( button ) = > {
const action = button . dataset . profileAction ;
button . disabled = ! enabled | | ( ( action == = " duplicate " | | action == = " delete " ) & & ! currentProfileId ) ;
} ) ;
} ;
const renderProfiles = ( ) = > {
profileSelect . innerHTML = " " ;
const unsaved = document . createElement ( " option " ) ;
unsaved . value = " " ;
unsaved . textContent = " Unsaved exploration " ;
profileSelect . appendChild ( unsaved ) ;
profiles
. slice ( )
. sort ( ( left , right ) = > String ( left . name ) . localeCompare ( String ( right . name ) ) )
. forEach ( ( profile ) = > {
const option = document . createElement ( " option " ) ;
option . value = profile . id ;
option . textContent = profile . name | | profile . id ;
profileSelect . appendChild ( option ) ;
} ) ;
if ( ! profiles . some ( ( profile ) = > profile . id == = currentProfileId ) ) currentProfileId = " " ;
profileSelect . value = currentProfileId ;
const profile = selectedProfile ( ) ;
profileNameInput . value = profile ? profile . name : " " ;
updateProfileControls ( ) ;
updateProfileSummary ( ) ;
} ;
const saveCurrentProfile = ( name , sourceProfile = null ) = > {
const now = new Date ( ) . toISOString ( ) ;
2026-05-19 01:46:23 +02:00
const profileName = name . trim ( ) | | sourceProfile ? . name | | orientationContext ? . profileName | | ` Fabric view $ { profiles . length + 1 } ` ;
2026-05-18 20:14:07 +02:00
if ( sourceProfile ) {
const profile = {
. . . sourceProfile ,
id : ` local - $ { Date . now ( ) . toString ( 36 ) } - $ { Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } ` ,
name : profileName ,
createdAt : now ,
updatedAt : now ,
} ;
profiles = [ . . . profiles , profile ] ;
currentProfileId = profile . id ;
} else if ( currentProfileId ) {
profiles = profiles . map ( ( profile ) = > profile . id == = currentProfileId
? { . . . profile , name : profileName , state : currentViewState ( ) , updatedAt : now }
: profile ) ;
} else {
const profile = {
id : ` local - $ { Date . now ( ) . toString ( 36 ) } - $ { Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } ` ,
name : profileName ,
state : currentViewState ( ) ,
createdAt : now ,
updatedAt : now ,
} ;
profiles = [ . . . profiles , profile ] ;
currentProfileId = profile . id ;
}
persistProfiles ( ) ;
renderProfiles ( ) ;
updateUrlState ( ) ;
} ;
2026-05-18 17:11:36 +02:00
const matchesFilters = ( element ) = > {
const data = element . data ( ) ;
2026-05-18 18:31:25 +02:00
const modeSet = visibleSetForMode ( ) ;
if ( modeSet & & ! modeSet . has ( element . id ( ) ) ) return false ;
2026-05-18 17:11:36 +02:00
const text = searchInput . value . trim ( ) . toLowerCase ( ) ;
if ( text & & ! elementText ( data ) . includes ( text ) ) return false ;
2026-05-18 23:58:58 +02:00
if ( element . isNode ( ) & & ! selectedNodeTypes ( ) . has ( data . layer ) ) return false ;
if ( element . isEdge ( ) & & ! selectedEdgeTypes ( ) . has ( data . edgeType ) ) return false ;
2026-05-18 17:11:36 +02:00
if ( reviewFilter . value & & data . reviewState != = reviewFilter . value ) return false ;
if ( unresolvedFilter . value == = " true " & & data . unresolved != = true ) return false ;
if ( focusSet & & ! focusSet . has ( element . id ( ) ) ) return false ;
return true ;
} ;
2026-05-18 18:31:25 +02:00
const visibleSetForMode = ( ) = > {
if ( ! cy ) return null ;
if ( focusSet ) return focusSet ;
if ( activeMode == = " full " ) return null ;
if ( activeMode == = " onboarding-gaps " ) {
return new Set ( cy . elements ( ) . filter ( ( element ) = >
element . data ( " lifecycle " ) == = " registered-only " | | element . data ( " unresolved " ) == = true
) . map ( ( element ) = > element . id ( ) ) ) ;
}
if ( activeMode == = " unresolved " ) {
return new Set ( cy . elements ( ) . filter ( ( element ) = > element . data ( " unresolved " ) == = true ) . map ( ( element ) = > element . id ( ) ) ) ;
}
if ( ! selected ) return null ;
if ( activeMode == = " selected-path " ) {
const collection = selected . union ( selected . predecessors ( ) ) . union ( selected . successors ( ) ) ;
return new Set ( collection . map ( ( element ) = > element . id ( ) ) ) ;
}
if ( activeMode == = " neighborhood " ) {
const collection = selected . union ( selected . neighborhood ( ) ) . union ( selected . neighborhood ( ) . neighborhood ( ) ) ;
return new Set ( collection . map ( ( element ) = > element . id ( ) ) ) ;
}
return null ;
} ;
2026-05-19 01:16:18 +02:00
const applyFilters = ( options = { } ) = > {
2026-05-18 17:11:36 +02:00
if ( ! cy ) return ;
2026-05-19 01:16:18 +02:00
const previousRemoved = options . redrawOnRemove ? ruleRemovalSignature ( ) : " " ;
2026-05-18 23:58:58 +02:00
syncFilterSummaries ( ) ;
2026-05-18 17:11:36 +02:00
const hiddenNodes = new Set ( ) ;
2026-05-19 01:16:18 +02:00
const removedNodes = new Set ( ) ;
2026-05-18 17:11:36 +02:00
cy . nodes ( ) . forEach ( ( node ) = > {
let state = matchesFilters ( node ) ? " show " : " hide " ;
2026-05-19 01:16:18 +02:00
const ruleAction = ruleActionFor ( node ) ;
if ( ruleAction ) state = ruleAction ;
if ( state != = " remove " ) {
const override = manualOverrideFor ( node ) ;
if ( override ) state = override ;
}
2026-05-18 17:11:36 +02:00
node . data ( " displayState " , state ) ;
node . toggleClass ( " display-blur " , state == = " blur " ) ;
2026-05-19 01:16:18 +02:00
node . toggleClass ( " rule-highlight " , state == = " highlight " ) ;
node . style ( " display " , state == = " hide " | | state == = " remove " ? " none " : " element " ) ;
if ( state == = " remove " ) removedNodes . add ( node . id ( ) ) ;
2026-05-18 17:11:36 +02:00
if ( state == = " hide " ) hiddenNodes . add ( node . id ( ) ) ;
} ) ;
cy . edges ( ) . forEach ( ( edge ) = > {
let state = matchesFilters ( edge ) ? " show " : " hide " ;
2026-05-19 01:16:18 +02:00
const ruleAction = ruleActionFor ( edge ) ;
if ( ruleAction ) state = ruleAction ;
if ( removedNodes . has ( edge . data ( " source " ) ) | | removedNodes . has ( edge . data ( " target " ) ) ) {
state = " remove " ;
} else if ( hiddenNodes . has ( edge . data ( " source " ) ) | | hiddenNodes . has ( edge . data ( " target " ) ) ) {
state = " hide " ;
}
if ( state != = " remove " ) {
const override = manualOverrideFor ( edge ) ;
if ( override ) state = override ;
}
2026-05-18 17:11:36 +02:00
edge . data ( " displayState " , state ) ;
edge . toggleClass ( " display-blur " , state == = " blur " ) ;
2026-05-19 01:16:18 +02:00
edge . toggleClass ( " rule-highlight " , state == = " highlight " ) ;
edge . style ( " display " , state == = " hide " | | state == = " remove " ? " none " : " element " ) ;
2026-05-18 17:11:36 +02:00
} ) ;
const visibleNodes = cy . nodes ( ) . filter ( ( node ) = > node . style ( " display " ) != = " none " ) . length ;
const visibleEdges = cy . edges ( ) . filter ( ( edge ) = > edge . style ( " display " ) != = " none " ) . length ;
2026-05-19 01:16:18 +02:00
const removed = cy . elements ( ) . filter ( ( element ) = > element . data ( " displayState " ) == = " remove " ) . length ;
const hidden = cy . elements ( ) . length - visibleNodes - visibleEdges - removed ;
2026-05-18 17:11:36 +02:00
counts . textContent = ` $ { visibleNodes } nodes / $ { visibleEdges } edges ` ;
2026-05-19 01:16:18 +02:00
hiddenSummary . textContent = removed ? ` Hidden $ { hidden } / Removed $ { removed } ` : ` Hidden $ { hidden } ` ;
2026-05-19 00:27:09 +02:00
updateLabelVisibility ( ) ;
2026-05-19 00:16:57 +02:00
updateSelectionAnchor ( ) ;
2026-05-19 01:16:18 +02:00
if ( options . redrawOnRemove & & previousRemoved != = ruleRemovalSignature ( ) ) runLayout ( ) ;
2026-05-18 17:11:36 +02:00
} ;
const showDetails = ( element ) = > {
selected = element | | null ;
if ( ! element ) {
2026-05-19 01:46:23 +02:00
orientationContext = null ;
2026-05-18 17:11:36 +02:00
detailTitle . textContent = " Fabric Map " ;
detailSummary . textContent = " No selection " ;
detailPills . innerHTML = " " ;
detailList . innerHTML = " " ;
2026-05-18 18:36:15 +02:00
orientationTitle . textContent = " Select a service, interface, dependency, or registered-only repo. " ;
orientationList . innerHTML = " " ;
2026-05-19 01:46:23 +02:00
orientationActions . innerHTML = " " ;
2026-05-19 00:27:09 +02:00
updateLabelVisibility ( ) ;
2026-05-19 00:16:57 +02:00
updateSelectionAnchor ( ) ;
2026-05-18 17:11:36 +02:00
return ;
}
const data = element . data ( ) ;
detailTitle . textContent = data . name | | data . label | | data . id ;
detailSummary . textContent = data . description | | data . id ;
2026-05-18 23:58:58 +02:00
const nodeType = data . layer ? nodeTypeLabels [ data . layer ] | | humanize ( data . layer ) : " " ;
2026-05-23 14:00:59 +02:00
detailPills . innerHTML = [ data . kind , nodeType , data . canonCategory | | data . canonicalType , data . evidenceState , data . repo , data . reviewState , data . displayState ]
2026-05-18 23:58:58 +02:00
. map ( ( value ) = > value ? ` < span class = " pill " > $ { escapeHtml ( value ) } < / span > ` : " " )
2026-05-18 17:11:36 +02:00
. join ( " " ) ;
const links = data . deepLinks | | { } ;
const refs = data . sourceReferences | | [ ] ;
const rows = [
[ " id " , data . id ] ,
[ " source " , data . source ] ,
[ " target " , data . target ] ,
[ " edge " , data . edgeType ] ,
2026-05-23 14:00:59 +02:00
[ " canonical " , data . canonicalType | | data . canonCategory ] ,
[ " evidence " , data . evidenceState ] ,
[ " mapping " , data . mappingFit ] ,
[ " display only " , data . displayOnly == = true ? " yes " : " " ] ,
2026-05-18 17:11:36 +02:00
[ " strength " , data . strength ] ,
. . . Object . entries ( links ) ,
. . . refs . map ( ( ref ) = > [ ref . label | | ref . kind | | " source " , ref . path | | ref . url | | ref . ref | | " " ] )
] ;
detailList . innerHTML = rows
. filter ( ( [ , value ] ) = > value )
. map ( ( [ key , value ] ) = > ` < li > < strong > $ { escapeHtml ( key ) } < / strong > $ { escapeHtml ( value ) } < / li > ` )
. join ( " " ) ;
2026-05-18 18:36:15 +02:00
renderOrientation ( element ) ;
2026-05-19 00:27:09 +02:00
updateLabelVisibility ( ) ;
2026-05-19 00:16:57 +02:00
updateSelectionAnchor ( ) ;
2026-05-18 18:36:15 +02:00
} ;
const renderOrientation = ( element ) = > {
const data = element . data ( ) ;
2026-05-19 01:46:23 +02:00
const contextIds = new Set ( [ element . id ( ) ] ) ;
2026-05-18 18:36:15 +02:00
const rows = [ ] ;
2026-05-19 01:46:23 +02:00
let title = " Graph context " ;
let profileName = ` Fabric context : $ { elementLabel ( element ) } ` ;
2026-05-18 18:36:15 +02:00
if ( data . kind == = " Repository " & & data . lifecycle == = " registered-only " ) {
2026-05-19 01:46:23 +02:00
title = " Onboarding gap " ;
profileName = ` Onboarding gap : $ { data . repo | | elementLabel ( element ) } ` ;
rows . push (
{ label : " repo " , value : data . repo | | data . id } ,
{ label : " status " , value : " registered without an accepted Fabric graph snapshot " , state : " warning " } ,
{ label : " next " , value : " add Fabric declarations or run registry sync once a snapshot exists " }
) ;
2026-05-18 18:36:15 +02:00
} else if ( data . kind == = " InterfaceDeclaration " ) {
2026-05-19 01:46:23 +02:00
title = " Interface consumers and impact " ;
profileName = ` Interface impact : $ { elementLabel ( element ) } ` ;
const attributes = data . attributes | | { } ;
if ( attributes . service_id ) {
addContextNode ( contextIds , attributes . service_id ) ;
rows . push ( { label : " provider service " , value : nodeName ( attributes . service_id ) , state : " good " } ) ;
}
edgeCollection ( " " , data . id , " available_via " ) . forEach ( ( edge ) = > {
addContextEdge ( contextIds , edge ) ;
const capability = edge . data ( " source " ) ;
edgeCollection ( " " , capability , " provides " ) . forEach ( ( providerEdge ) = > {
addContextEdge ( contextIds , providerEdge ) ;
rows . push ( {
label : " provider surface " ,
value : ` $ { nodeName ( providerEdge . data ( " source " ) ) } - > $ { nodeName ( capability ) } - > $ { elementLabel ( element ) } ` ,
path : true ,
state : " good " ,
} ) ;
} ) ;
} ) ;
let consumerCount = 0 ;
edgeCollection ( " " , data . id , " uses_interface " ) . forEach ( ( edge ) = > {
addContextEdge ( contextIds , edge ) ;
2026-05-18 18:36:15 +02:00
const dependency = edge . data ( " source " ) ;
2026-05-19 01:46:23 +02:00
const dependencyNode = nodeById ( dependency ) ;
edgeCollection ( " " , dependency , " consumes " ) . forEach ( ( consumerEdge ) = > {
consumerCount + = 1 ;
addContextEdge ( contextIds , consumerEdge ) ;
rows . push ( {
label : " consumer " ,
value : ` $ { nodeName ( consumerEdge . data ( " source " ) ) } - > $ { nodeName ( dependency ) } - > $ { elementLabel ( element ) } ` ,
path : true ,
state : dependencyNode ? . data ( " unresolved " ) == = true ? " warning " : " " ,
} ) ;
} ) ;
2026-05-18 18:36:15 +02:00
} ) ;
2026-05-19 01:46:23 +02:00
if ( consumerCount == = 0 ) rows . push ( { label : " consumer " , value : " no accepted consumers in current graph " } ) ;
2026-05-18 18:36:15 +02:00
} else if ( data . kind == = " ServiceDeclaration " ) {
2026-05-19 01:46:23 +02:00
title = " Service dependency chain " ;
profileName = ` Service map : $ { elementLabel ( element ) } ` ;
let dependencyCount = 0 ;
edgeCollection ( data . id , " " , " consumes " ) . forEach ( ( edge ) = > {
dependencyCount + = 1 ;
addContextEdge ( contextIds , edge ) ;
2026-05-18 18:36:15 +02:00
const dependency = edge . data ( " target " ) ;
const providerEdges = cy . edges ( ) . filter ( ( candidate ) = >
2026-05-19 01:46:23 +02:00
candidate . data ( " source " ) == = dependency & & (
String ( candidate . data ( " edgeType " ) ) . startsWith ( " binds: " ) | |
candidate . data ( " edgeType " ) == = " uses_interface "
)
2026-05-18 18:36:15 +02:00
) ;
if ( providerEdges . length == = 0 ) {
2026-05-19 01:46:23 +02:00
rows . push ( {
label : " requires " ,
value : ` $ { elementLabel ( element ) } - > $ { nodeName ( dependency ) } - > unresolved ` ,
path : true ,
state : " warning " ,
} ) ;
2026-05-18 18:36:15 +02:00
return ;
}
providerEdges . forEach ( ( providerEdge ) = > {
2026-05-19 01:46:23 +02:00
addContextEdge ( contextIds , providerEdge ) ;
2026-05-18 18:36:15 +02:00
const target = providerEdge . data ( " target " ) ;
2026-05-19 01:46:23 +02:00
rows . push ( {
label : " requires " ,
value : ` $ { elementLabel ( element ) } - > $ { nodeName ( dependency ) } - > $ { nodeName ( target ) } ( $ { providerEdge . data ( " edgeType " ) } ) ` ,
path : true ,
state : String ( providerEdge . data ( " edgeType " ) ) . includes ( " missing " ) ? " warning " : " good " ,
} ) ;
} ) ;
} ) ;
if ( dependencyCount == = 0 ) rows . push ( { label : " requires " , value : " no declared dependencies " } ) ;
edgeCollection ( data . id , " " , " deployed_as " ) . forEach ( ( edge ) = > {
addContextEdge ( contextIds , edge ) ;
const deployment = edge . data ( " target " ) ;
const runtimeEdges = edgeCollection ( deployment , " " , " runs_on " ) ;
if ( runtimeEdges . length == = 0 ) {
rows . push ( { label : " deployment " , value : ` $ { nodeName ( deployment ) } - > no server binding ` , path : true , state : " warning " } ) ;
return ;
}
runtimeEdges . forEach ( ( runtimeEdge ) = > {
addContextEdge ( contextIds , runtimeEdge ) ;
rows . push ( {
label : " runtime " ,
value : ` $ { nodeName ( deployment ) } - > $ { nodeName ( runtimeEdge . data ( " target " ) ) } ` ,
path : true ,
state : " good " ,
} ) ;
2026-05-18 18:36:15 +02:00
} ) ;
} ) ;
} else if ( data . kind == = " DependencyDeclaration " ) {
2026-05-19 01:46:23 +02:00
title = " Dependency binding " ;
profileName = ` Dependency binding : $ { elementLabel ( element ) } ` ;
edgeCollection ( " " , data . id , " consumes " ) . forEach ( ( edge ) = > {
addContextEdge ( contextIds , edge ) ;
rows . push ( {
label : " consumer " ,
value : ` $ { nodeName ( edge . data ( " source " ) ) } - > $ { elementLabel ( element ) } ` ,
path : true ,
} ) ;
} ) ;
2026-05-18 18:36:15 +02:00
cy . edges ( ) . filter ( ( edge ) = >
edge . data ( " source " ) == = data . id & & (
String ( edge . data ( " edgeType " ) ) . startsWith ( " binds: " ) | |
edge . data ( " edgeType " ) == = " uses_interface "
)
) . forEach ( ( edge ) = > {
2026-05-19 01:46:23 +02:00
addContextEdge ( contextIds , edge ) ;
2026-05-18 18:36:15 +02:00
const target = edge . data ( " target " ) ;
2026-05-19 01:46:23 +02:00
rows . push ( {
label : edge . data ( " edgeType " ) ,
value : ` $ { elementLabel ( element ) } - > $ { nodeName ( target ) } ` ,
path : true ,
state : String ( edge . data ( " edgeType " ) ) . includes ( " missing " ) ? " warning " : " good " ,
} ) ;
2026-05-18 18:36:15 +02:00
} ) ;
2026-05-19 01:46:23 +02:00
if ( rows . length == = 0 ) rows . push ( { label : " binding " , value : " no provider binding in current graph " , state : " warning " } ) ;
2026-05-18 18:36:15 +02:00
} else if ( data . kind == = " CapabilityDeclaration " ) {
2026-05-19 01:46:23 +02:00
title = " Provider surface " ;
profileName = ` Provider surface : $ { elementLabel ( element ) } ` ;
edgeCollection ( " " , data . id , " provides " ) . forEach ( ( edge ) = > {
addContextEdge ( contextIds , edge ) ;
rows . push ( { label : " service " , value : ` $ { nodeName ( edge . data ( " source " ) ) } - > $ { elementLabel ( element ) } ` , path : true , state : " good " } ) ;
} ) ;
edgeCollection ( data . id , " " , " available_via " ) . forEach ( ( edge ) = > {
addContextEdge ( contextIds , edge ) ;
rows . push ( { label : " interface " , value : ` $ { elementLabel ( element ) } - > $ { nodeName ( edge . data ( " target " ) ) } ` , path : true , state : " good " } ) ;
} ) ;
if ( rows . length == = 0 ) rows . push ( { label : " surface " , value : " no provider surface in current graph " } ) ;
2026-05-18 18:36:15 +02:00
} else {
2026-05-19 01:46:23 +02:00
const neighborhood = element . neighborhood ( ) ;
neighborhood . forEach ( ( item ) = > addContextElement ( contextIds , item ) ) ;
rows . push ( { label : " neighbors " , value : ` $ { neighborhood . nodes ( ) . length } connected nodes ` } ) ;
2026-05-18 18:36:15 +02:00
}
2026-05-19 01:46:23 +02:00
orientationContext = {
ids : Array . from ( contextIds ) ,
profileName ,
} ;
orientationTitle . textContent = ` $ { title } ( $ { orientationContext . ids . length } items ) ` ;
2026-05-18 18:36:15 +02:00
orientationList . innerHTML = rows
2026-05-19 01:46:23 +02:00
. map ( ( row ) = > `
< li class = " orientation-item $ { orientationStateClass(row.state)} " >
< span class = " orientation-label " > $ { escapeHtml ( row . label ) } < / span >
< span class = " orientation-value $ { row.path ? " orientation - path " : " " } " > $ { escapeHtml ( row . value ) } < / span >
< / li >
` )
2026-05-18 18:36:15 +02:00
. join ( " " ) ;
2026-05-19 01:46:23 +02:00
orientationActions . innerHTML = `
< button type = " button " data - orientation - action = " focus " > Focus Context < / button >
< button type = " button " data - orientation - action = " highlight " > Highlight Context < / button >
< button type = " button " data - orientation - action = " hide-other " > Hide Other < / button >
< button type = " button " data - orientation - action = " remove-other " > Remove Other < / button >
< button type = " button " data - orientation - action = " name-view " > Name View < / button >
` ;
} ;
const applyOrientationContext = ( action ) = > {
if ( ! cy | | ! orientationContext ) return ;
const contextIds = new Set ( orientationContext . ids ) ;
if ( action == = " focus " ) {
focusSet = contextIds ;
modeSelect . value = " neighborhood " ;
activeMode = " neighborhood " ;
} else if ( action == = " highlight " ) {
contextIds . forEach ( ( id ) = > { manualOverrides [ id ] = " highlight " ; } ) ;
} else if ( action == = " hide-other " | | action == = " remove-other " ) {
const otherState = action == = " remove-other " ? " remove " : " hide " ;
cy . elements ( ) . forEach ( ( item ) = > {
manualOverrides [ item . id ( ) ] = contextIds . has ( item . id ( ) ) ? " show " : otherState ;
} ) ;
} else if ( action == = " name-view " ) {
if ( supportsLocalProfiles ( ) ) {
profileNameInput . value = orientationContext . profileName ;
updateProfileSummary ( ` Prepared view name " $ {orientationContext.profileName} " . ` ) ;
}
return ;
}
currentProfileId = " " ;
profileSelect . value = " " ;
applyFilters ( { redrawOnRemove : action == = " remove-other " } ) ;
updateProfileControls ( ) ;
updateProfileSummary ( ) ;
updateUrlState ( ) ;
2026-05-18 17:11:36 +02:00
} ;
const applyFocus = ( ) = > {
if ( ! cy | | ! selected ) return ;
const collection = selected . union ( selected . neighborhood ( ) ) . union ( selected . neighborhood ( ) . neighborhood ( ) ) ;
focusSet = new Set ( collection . map ( ( item ) = > item . id ( ) ) ) ;
2026-05-18 18:31:25 +02:00
modeSelect . value = " neighborhood " ;
activeMode = " neighborhood " ;
2026-05-18 20:14:07 +02:00
currentProfileId = " " ;
profileSelect . value = " " ;
2026-05-18 17:11:36 +02:00
applyFilters ( ) ;
2026-05-18 20:14:07 +02:00
updateProfileControls ( ) ;
updateProfileSummary ( ) ;
updateUrlState ( ) ;
2026-05-18 17:11:36 +02:00
} ;
2026-05-18 22:39:12 +02:00
const edgeIdealLength = ( edge ) = > Number ( edge . data ( " layoutIdealLength " ) ) | | 110 ;
const edgeElasticity = ( edge ) = > Number ( edge . data ( " layoutElasticity " ) ) | | 80 ;
2026-05-18 18:31:25 +02:00
const runLayout = ( ) = > {
if ( ! cy ) return ;
cy . elements ( ) . stop ( ) ;
2026-05-19 01:16:18 +02:00
const layoutElements = cy . elements ( ) . filter ( ( element ) = > element . data ( " displayState " ) != = " remove " ) ;
2026-05-18 18:31:25 +02:00
const name = layoutSelect . value | | " cose " ;
const options = name == = " breadthfirst "
? { name , directed : true , padding : 48 , animate : false }
2026-05-18 22:39:12 +02:00
: name == = " cose "
? {
name ,
padding : 48 ,
animate : false ,
randomize : false ,
nodeOverlap : 12 ,
idealEdgeLength : edgeIdealLength ,
edgeElasticity ,
nodeRepulsion : 5000 ,
gravity : 1 ,
numIter : 1400 ,
}
: { name , padding : 48 , animate : false } ;
2026-05-19 01:16:18 +02:00
layoutElements . layout ( options ) . run ( ) ;
2026-05-19 00:16:57 +02:00
updateSelectionAnchor ( ) ;
2026-05-18 18:31:25 +02:00
} ;
2026-05-18 17:11:36 +02:00
const renderLegend = ( layers ) = > {
2026-05-19 00:27:09 +02:00
const nodeTypes = layers . map ( ( layer ) = >
2026-05-18 17:11:36 +02:00
` < span class = " pill " > < span style = " display:inline-block;width:10px;height:10px;border-radius:50 % ;background:$ { escapeHtml(layer.color || " #667085")}"></span>${escapeHtml(layer.label)}</span>`
2026-05-19 00:27:09 +02:00
) ;
const statusItems = [
' <span class= " pill " ><span style= " display:inline-block;width:12px;height:12px;border:2px dashed #b45309;border-radius:50 % " ></span>Unresolved</span> ' ,
' <span class= " pill " ><span style= " display:inline-block;width:12px;height:12px;border:2px dashed #be123c;border-radius:3px " ></span>Registered only</span> ' ,
' <span class= " pill " ><span style= " display:inline-block;width:18px;border-top:2px dotted #98a2b3 " ></span>Weak edge</span> ' ,
] ;
legend . innerHTML = [ . . . nodeTypes , . . . statusItems ] . join ( " " ) ;
2026-05-18 17:11:36 +02:00
} ;
const boot = async ( ) = > {
const [ manifest , payload ] = await Promise . all ( [
fetch ( manifestUrl ) . then ( ( response ) = > response . json ( ) ) ,
fetch ( graphUrl ) . then ( ( response ) = > response . json ( ) )
] ) ;
2026-05-18 18:31:25 +02:00
( manifest . modes | | [ { id : " full " , label : " Full " } ] ) . forEach ( ( mode ) = > {
const option = document . createElement ( " option " ) ;
option . value = mode . id ;
option . textContent = mode . label ;
modeSelect . appendChild ( option ) ;
} ) ;
2026-05-18 20:14:07 +02:00
profilePersistence = manifest . profile_persistence | | " none " ;
2026-05-18 17:11:36 +02:00
layerColors = Object . fromEntries ( ( manifest . layers | | [ ] ) . map ( ( layer ) = > [ layer . id , layer . color ] ) ) ;
2026-05-18 23:58:58 +02:00
nodeTypeLabels = Object . fromEntries ( ( manifest . layers | | [ ] ) . map ( ( layer ) = > [ layer . id , layer . label ] ) ) ;
allNodeTypes = ( manifest . layers | | [ ] ) . map ( ( layer ) = > layer . id ) ;
allEdgeTypes = Array . from ( new Set ( ( payload . elements | | [ ] )
. map ( ( element ) = > element . data | | { } )
. filter ( ( data ) = > data . source & & data . target & & data . edgeType )
. map ( ( data ) = > data . edgeType )
) ) . sort ( ( left , right ) = > left . localeCompare ( right ) ) ;
renderChecklist ( nodeTypeFilter , allNodeTypes , nodeTypeLabels , " node-type " ) ;
renderChecklist ( edgeTypeFilter , allEdgeTypes , { } , " edge-type " ) ;
syncFilterSummaries ( ) ;
2026-05-18 17:11:36 +02:00
renderLegend ( manifest . layers | | [ ] ) ;
2026-05-18 20:14:07 +02:00
profiles = loadProfiles ( ) ;
renderProfiles ( ) ;
2026-05-18 17:11:36 +02:00
const elements = ( payload . elements | | [ ] ) . map ( ( element ) = > ( {
. . . element ,
data : { . . . element . data , color : layerColors [ element . data . layer ] | | " #667085 " }
} ) ) ;
2026-05-19 00:51:38 +02:00
if ( elements . length == = 0 ) {
canvas . innerHTML = " <p class= ' canvas-notice ' >No graph entities were returned by the registry.</p> " ;
counts . textContent = " 0 nodes / 0 edges " ;
hiddenSummary . textContent = " Hidden 0 " ;
return ;
}
canvas . innerHTML = " " ;
2026-05-18 17:11:36 +02:00
cy = cytoscape ( {
container : canvas ,
elements ,
2026-05-18 18:31:25 +02:00
layout : { name : layoutSelect . value | | " cose " , animate : false , randomize : false } ,
2026-05-18 17:11:36 +02:00
style : [
{ selector : " node " , style : {
" background-color " : " data(color) " ,
" border-color " : " #172033 " ,
" border-width " : 1 ,
" color " : " #172033 " ,
" font-size " : 11 ,
" height " : " data(visualSize) " ,
" label " : " data(label) " ,
" text-background-color " : " #ffffff " ,
" text-background-opacity " : .85 ,
" text-background-padding " : 2 ,
" text-wrap " : " wrap " ,
" text-max-width " : 130 ,
" width " : " data(visualSize) "
} } ,
2026-05-19 00:16:57 +02:00
{ selector : " node[layer = ' repository ' ] " , style : { " shape " : " round-rectangle " } } ,
{ selector : " node[layer = ' server ' ] " , style : { " shape " : " pentagon " } } ,
{ selector : " node[layer = ' deployment ' ] " , style : { " shape " : " diamond " } } ,
{ selector : " node[layer = ' service ' ] " , style : { " shape " : " hexagon " } } ,
{ selector : " node[layer = ' capability ' ] " , style : { " shape " : " vee " } } ,
{ selector : " node[layer = ' interface ' ] " , style : { " shape " : " rectangle " } } ,
{ selector : " node[layer = ' dependency ' ] " , style : { " shape " : " triangle " } } ,
{ selector : " node[layer = ' binding ' ] " , style : { " shape " : " rhomboid " } } ,
{ selector : " node[unresolved = true] " , style : { " border-color " : " #b45309 " , " border-style " : " dashed " , " border-width " : 3 } } ,
{ selector : " node[lifecycle = ' registered-only ' ] " , style : { " border-color " : " #be123c " , " border-style " : " dashed " , " border-width " : 3 } } ,
2026-05-18 17:11:36 +02:00
{ selector : " edge " , style : {
" curve-style " : " bezier " ,
" line-color " : " #98a2b3 " ,
" target-arrow-color " : " #98a2b3 " ,
" target-arrow-shape " : " triangle " ,
" width " : " data(edgeWidth) "
} } ,
{ selector : " edge[strength = ' strong ' ] " , style : { " line-color " : " #344054 " , " target-arrow-color " : " #344054 " } } ,
{ selector : " edge[strength = ' weak ' ] " , style : { " line-style " : " dotted " } } ,
2026-05-19 01:16:18 +02:00
{ selector : " node.rule-highlight " , style : {
" border-color " : " #2563eb " ,
" border-width " : 3 ,
" z-index " : 3
} } ,
{ selector : " edge.rule-highlight " , style : {
" line-color " : " #2563eb " ,
" target-arrow-color " : " #2563eb " ,
" width " : 5 ,
" z-index " : 3
} } ,
2026-05-19 00:16:57 +02:00
{ selector : " edge.hover " , style : {
" label " : " data(edgeType) " ,
" font-size " : 10 ,
" text-background-color " : " #ffffff " ,
" text-background-opacity " : .9 ,
" text-background-padding " : 2 ,
" z-index " : 2
} } ,
2026-05-18 17:11:36 +02:00
{ selector : " .display-blur " , style : { " opacity " : .24 , " label " : " " } } ,
2026-05-19 00:27:09 +02:00
{ selector : " .display-blur.hover " , style : { " opacity " : .78 , " label " : " data(label) " } } ,
{ selector : " .label-hidden " , style : { " label " : " " } } ,
2026-05-19 00:16:57 +02:00
{ selector : " :selected " , style : { " overlay-opacity " : 0 } }
2026-05-18 17:11:36 +02:00
]
} ) ;
2026-05-19 01:16:18 +02:00
refreshRuleBuilder ( ) ;
renderRules ( ) ;
2026-05-18 17:11:36 +02:00
cy . on ( " tap " , " node, edge " , ( event ) = > showDetails ( event . target ) ) ;
cy . on ( " tap " , ( event ) = > { if ( event . target == = cy ) showDetails ( null ) ; } ) ;
2026-05-19 00:16:57 +02:00
cy . on ( " pan zoom resize render layoutstop " , updateSelectionAnchor ) ;
cy . on ( " position " , " node " , updateSelectionAnchor ) ;
2026-05-18 17:11:36 +02:00
cy . on ( " mouseover " , " node, edge " , ( event ) = > {
event . target . addClass ( " hover " ) ;
const data = event . target . data ( ) ;
2026-05-18 23:58:58 +02:00
const nodeType = data . layer ? nodeTypeLabels [ data . layer ] | | humanize ( data . layer ) : " " ;
popup . innerHTML = ` < strong > $ { escapeHtml ( data . name | | data . label | | data . id ) } < / strong > < span class = " meta " > $ { escapeHtml ( data . kind ) } / $ { escapeHtml ( nodeType | | data . edgeType | | " " ) } < / span > ` ;
2026-05-18 17:11:36 +02:00
popup . style . left = ` $ { Math . min ( event . renderedPosition . x + 14 , canvas . clientWidth - 270 ) } px ` ;
popup . style . top = ` $ { Math . max ( 10 , event . renderedPosition . y + 14 ) } px ` ;
popup . style . display = " block " ;
} ) ;
cy . on ( " mouseout " , " node, edge " , ( event ) = > {
event . target . removeClass ( " hover " ) ;
popup . style . display = " none " ;
} ) ;
2026-05-18 20:14:07 +02:00
const urlState = readUrlState ( ) ;
if ( urlState . profile & & profiles . some ( ( profile ) = > profile . id == = urlState . profile ) ) {
currentProfileId = urlState . profile ;
profileSelect . value = currentProfileId ;
const profile = selectedProfile ( ) ;
profileNameInput . value = profile ? profile . name : " " ;
if ( profile ) applyViewState ( profile . state | | { } , { skipLayout : true , skipUrl : true } ) ;
}
applyViewState ( urlState , { skipUrl : true } ) ;
2026-05-18 18:31:25 +02:00
runLayout ( ) ;
2026-05-18 20:14:07 +02:00
updateUrlState ( ) ;
2026-05-18 17:11:36 +02:00
} ;
2026-05-18 23:58:58 +02:00
const handleFilterChange = ( ) = > {
focusSet = null ;
currentProfileId = " " ;
profileSelect . value = " " ;
applyFilters ( ) ;
updateProfileControls ( ) ;
updateProfileSummary ( ) ;
updateUrlState ( ) ;
} ;
[ searchInput , reviewFilter , unresolvedFilter ] . forEach ( ( control ) = > {
control . addEventListener ( " input " , handleFilterChange ) ;
2026-05-18 17:11:36 +02:00
} ) ;
2026-05-18 23:58:58 +02:00
nodeTypeFilter . addEventListener ( " change " , handleFilterChange ) ;
edgeTypeFilter . addEventListener ( " change " , handleFilterChange ) ;
2026-05-18 18:31:25 +02:00
modeSelect . addEventListener ( " input " , ( ) = > {
activeMode = modeSelect . value | | " full " ;
focusSet = null ;
2026-05-18 20:14:07 +02:00
currentProfileId = " " ;
profileSelect . value = " " ;
2026-05-18 18:31:25 +02:00
applyFilters ( ) ;
2026-05-18 20:14:07 +02:00
updateProfileControls ( ) ;
updateProfileSummary ( ) ;
updateUrlState ( ) ;
} ) ;
layoutSelect . addEventListener ( " input " , ( ) = > {
currentProfileId = " " ;
profileSelect . value = " " ;
runLayout ( ) ;
updateProfileControls ( ) ;
updateProfileSummary ( ) ;
updateUrlState ( ) ;
2026-05-18 18:31:25 +02:00
} ) ;
2026-05-19 00:27:09 +02:00
labelSelect . addEventListener ( " input " , ( ) = > {
activeLabelMode = labelSelect . value | | " auto " ;
currentProfileId = " " ;
profileSelect . value = " " ;
updateLabelVisibility ( ) ;
updateProfileControls ( ) ;
updateProfileSummary ( ) ;
updateUrlState ( ) ;
} ) ;
2026-05-19 01:16:18 +02:00
ruleTarget . addEventListener ( " input " , ( ) = > refreshRuleBuilder ( { target : ruleTarget . value } ) ) ;
ruleType . addEventListener ( " input " , ( ) = > refreshRuleBuilder ( { target : ruleTarget . value , type : ruleType . value } ) ) ;
ruleAttribute . addEventListener ( " input " , ( ) = > refreshRuleBuilder ( {
target : ruleTarget . value ,
type : ruleType . value ,
attribute : ruleAttribute . value ,
} ) ) ;
document . querySelector ( " [data-rule-action= ' save ' ] " ) . addEventListener ( " click " , ( ) = > {
const rule = {
id : editingRuleId | | ` rule - $ { Date . now ( ) . toString ( 36 ) } - $ { Math . random ( ) . toString ( 36 ) . slice ( 2 , 8 ) } ` ,
target : ruleTarget . value | | " node " ,
type : ruleType . value | | " " ,
attribute : ruleAttribute . value | | " any " ,
value : ruleAttribute . value == = " any " ? " " : ruleValue . value | | " " ,
action : ruleAction . value | | " highlight " ,
} ;
filterRules = editingRuleId
? filterRules . map ( ( existing ) = > existing . id == = editingRuleId ? rule : existing )
: [ . . . filterRules , rule ] ;
editingRuleId = " " ;
renderRules ( ) ;
applyFilters ( { redrawOnRemove : true } ) ;
currentProfileId = " " ;
profileSelect . value = " " ;
updateProfileControls ( ) ;
updateProfileSummary ( ) ;
updateUrlState ( ) ;
} ) ;
document . querySelector ( " [data-rule-action= ' cancel ' ] " ) . addEventListener ( " click " , ( ) = > {
editingRuleId = " " ;
refreshRuleBuilder ( ) ;
renderRules ( ) ;
} ) ;
document . querySelector ( " [data-rule-action= ' reset ' ] " ) . addEventListener ( " click " , ( ) = > {
filterRules = [ ] ;
editingRuleId = " " ;
refreshRuleBuilder ( ) ;
renderRules ( ) ;
applyFilters ( { redrawOnRemove : true } ) ;
currentProfileId = " " ;
profileSelect . value = " " ;
updateProfileControls ( ) ;
updateProfileSummary ( ) ;
updateUrlState ( ) ;
} ) ;
ruleList . addEventListener ( " click " , ( event ) = > {
const button = event . target . closest ( " [data-rule-list-action] " ) ;
if ( ! button ) return ;
const item = button . closest ( " [data-rule-id] " ) ;
const index = filterRules . findIndex ( ( rule ) = > rule . id == = item ? . dataset ? . ruleId ) ;
if ( index < 0 ) return ;
const action = button . dataset . ruleListAction ;
if ( action == = " edit " ) {
const rule = filterRules [ index ] ;
editingRuleId = rule . id ;
refreshRuleBuilder ( rule ) ;
renderRules ( ) ;
return ;
}
if ( action == = " delete " ) {
filterRules = filterRules . filter ( ( _ , ruleIndex ) = > ruleIndex != = index ) ;
} else if ( action == = " up " & & index > 0 ) {
[ filterRules [ index - 1 ] , filterRules [ index ] ] = [ filterRules [ index ] , filterRules [ index - 1 ] ] ;
} else if ( action == = " down " & & index < filterRules . length - 1 ) {
[ filterRules [ index + 1 ] , filterRules [ index ] ] = [ filterRules [ index ] , filterRules [ index + 1 ] ] ;
}
editingRuleId = " " ;
renderRules ( ) ;
applyFilters ( { redrawOnRemove : true } ) ;
currentProfileId = " " ;
profileSelect . value = " " ;
updateProfileControls ( ) ;
updateProfileSummary ( ) ;
updateUrlState ( ) ;
} ) ;
2026-05-19 01:46:23 +02:00
orientationActions . addEventListener ( " click " , ( event ) = > {
const button = event . target . closest ( " [data-orientation-action] " ) ;
if ( ! button ) return ;
applyOrientationContext ( button . dataset . orientationAction ) ;
} ) ;
2026-05-18 17:11:36 +02:00
document . querySelector ( " [data-action= ' fit ' ] " ) . addEventListener ( " click " , ( ) = > cy & & cy . fit ( cy . elements ( " :visible " ) , 48 ) ) ;
document . querySelector ( " [data-action= ' focus ' ] " ) . addEventListener ( " click " , applyFocus ) ;
document . querySelector ( " [data-action= ' clear ' ] " ) . addEventListener ( " click " , ( ) = > {
searchInput . value = " " ;
2026-05-18 18:31:25 +02:00
modeSelect . value = " full " ;
activeMode = " full " ;
2026-05-19 00:27:09 +02:00
labelSelect . value = " auto " ;
activeLabelMode = " auto " ;
2026-05-18 23:58:58 +02:00
setCheckedValues ( nodeTypeFilter ) ;
setCheckedValues ( edgeTypeFilter ) ;
2026-05-18 17:11:36 +02:00
reviewFilter . value = " " ;
unresolvedFilter . value = " " ;
focusSet = null ;
2026-05-18 20:14:07 +02:00
manualOverrides = { } ;
2026-05-19 01:16:18 +02:00
filterRules = [ ] ;
editingRuleId = " " ;
refreshRuleBuilder ( ) ;
renderRules ( ) ;
2026-05-18 20:14:07 +02:00
currentProfileId = " " ;
profileSelect . value = " " ;
2026-05-19 01:16:18 +02:00
applyFilters ( { redrawOnRemove : true } ) ;
2026-05-18 20:14:07 +02:00
updateProfileControls ( ) ;
updateProfileSummary ( ) ;
updateUrlState ( ) ;
2026-05-18 17:11:36 +02:00
} ) ;
document . querySelector ( " [data-action= ' reset-overrides ' ] " ) . addEventListener ( " click " , ( ) = > {
manualOverrides = { } ;
2026-05-18 20:14:07 +02:00
currentProfileId = " " ;
profileSelect . value = " " ;
2026-05-18 17:11:36 +02:00
applyFilters ( ) ;
2026-05-18 20:14:07 +02:00
updateProfileControls ( ) ;
updateProfileSummary ( ) ;
updateUrlState ( ) ;
2026-05-18 17:11:36 +02:00
} ) ;
document . querySelectorAll ( " [data-override] " ) . forEach ( ( button ) = > {
button . addEventListener ( " click " , ( ) = > {
if ( ! selected ) return ;
2026-05-18 20:14:07 +02:00
manualOverrides [ overrideKey ( selected ) ] = button . dataset . override ;
currentProfileId = " " ;
profileSelect . value = " " ;
2026-05-18 17:11:36 +02:00
applyFilters ( ) ;
2026-05-18 20:14:07 +02:00
updateProfileControls ( ) ;
updateProfileSummary ( ) ;
updateUrlState ( ) ;
} ) ;
} ) ;
profileSelect . addEventListener ( " input " , ( ) = > {
currentProfileId = profileSelect . value ;
const profile = selectedProfile ( ) ;
profileNameInput . value = profile ? profile . name : " " ;
if ( profile ) applyViewState ( profile . state | | { } ) ;
if ( ! profile ) updateUrlState ( ) ;
updateProfileControls ( ) ;
updateProfileSummary ( ) ;
} ) ;
document . querySelectorAll ( " [data-profile-action] " ) . forEach ( ( button ) = > {
button . addEventListener ( " click " , async ( ) = > {
if ( ! supportsLocalProfiles ( ) ) return ;
const action = button . dataset . profileAction ;
if ( action == = " save " ) {
saveCurrentProfile ( profileNameInput . value ) ;
} else if ( action == = " duplicate " ) {
const profile = selectedProfile ( ) ;
if ( ! profile ) return ;
saveCurrentProfile ( ` Copy of $ { profile . name } ` , { . . . profile , state : currentViewState ( ) } ) ;
} else if ( action == = " delete " ) {
profiles = profiles . filter ( ( profile ) = > profile . id != = currentProfileId ) ;
currentProfileId = " " ;
persistProfiles ( ) ;
renderProfiles ( ) ;
updateUrlState ( ) ;
} else if ( action == = " copy " ) {
const url = ` $ { window . location . origin } $ { viewUrl ( true ) } ` ;
if ( window . navigator ? . clipboard ? . writeText ) await window . navigator . clipboard . writeText ( url ) ;
updateProfileSummary ( " Copied the current map state URL. " ) ;
}
2026-05-18 17:11:36 +02:00
} ) ;
} ) ;
2026-05-19 00:51:38 +02:00
document . querySelectorAll ( " [data-help] " ) . forEach ( ( target ) = > {
target . addEventListener ( " mouseenter " , ( ) = > showHelp ( target ) ) ;
target . addEventListener ( " focus " , ( ) = > showHelp ( target ) ) ;
target . addEventListener ( " mouseleave " , hideHelp ) ;
target . addEventListener ( " blur " , hideHelp ) ;
} ) ;
document . addEventListener ( " keydown " , ( event ) = > {
if ( event . key == = " Escape " ) hideHelp ( ) ;
} ) ;
2026-05-18 17:11:36 +02:00
if ( ! window . cytoscape ) {
canvas . innerHTML = " <p class= ' notice ' >Cytoscape.js could not be loaded.</p> " ;
return ;
}
boot ( ) . catch ( ( error ) = > {
2026-05-19 00:51:38 +02:00
canvas . innerHTML = ` < p class = ' canvas-notice ' > Could not load graph explorer data : $ { escapeHtml ( error . message ) } < / p > ` ;
2026-05-18 17:11:36 +02:00
} ) ;
} ) ( ) ;
< / script >
< / body >
< / html >
"""