2026-07-26 21:28:14 +02:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
2026-07-29 09:03:59 +02:00
< title > PR Data Meta Model · T-Box< / title >
2026-07-26 21:28:14 +02:00
< style >
: root {
2026-07-29 09:03:59 +02:00
--navy : #0A1F44 ; --gold : #B8935A ; --ink : #1A1A1A ; --bg : #F7F6F2 ;
--panel : #fff ; --rule : #E4E2DA ; --muted : #6B6B6B ; --soft : #F1EFE8 ;
--mono : ui-monospace , "SF Mono" , SFMono-Regular , Menlo , Consolas , monospace ;
2026-07-26 21:28:14 +02:00
--sans : - apple-system , BlinkMacSystemFont , "Segoe UI" , Helvetica , Arial , sans-serif ;
2026-07-29 09:03:59 +02:00
--serif : Georgia , "Times New Roman" , serif ;
2026-07-26 21:28:14 +02:00
}
* { box-sizing : border-box ; -webkit- tap-highlight-color : transparent }
2026-07-29 09:03:59 +02:00
html , body { margin : 0 ; height : 100 % }
2026-07-26 21:28:14 +02:00
body { font-family : var ( - - sans ) ; background : var ( - - bg ) ; color : var ( - - ink ) ;
display : flex ; flex-direction : column ; overflow : hidden }
: focus-visible { outline : 2 px solid var ( - - gold ) ; outline-offset : 2 px }
2026-07-29 09:03:59 +02:00
header { background : var ( - - navy ) ; color : #fff ; padding : 11 px 20 px ; display : flex ;
align-items : center ; gap : 18 px ; flex-shrink : 0 }
header h1 { margin : 0 ; font-family : var ( - - mono ) ; font-size : 12.5 px ; font-weight : 600 ;
letter-spacing : .17 em ; text-transform : uppercase }
header . sub { font-size : 11 px ; color : rgba ( 255 , 255 , 255 , .6 ) ; margin-top : 3 px }
. tabs { margin-left : auto ; display : flex ; gap : 2 px ; background : rgba ( 255 , 255 , 255 , .08 ) ;
padding : 3 px ; border-radius : 4 px }
. tabs button { padding : 6 px 14 px ; border : none ; background : transparent ; color : rgba ( 255 , 255 , 255 , .7 ) ;
font-family : var ( - - mono ) ; font-size : 10 px ; letter-spacing : .09 em ; text-transform : uppercase ;
cursor : pointer ; border-radius : 3 px ; font-weight : 600 }
. tabs button . on { background : #fff ; color : var ( - - navy ) }
main { flex : 1 ; overflow-y : auto ; padding : 22 px 20 px 60 px }
. wrap { max-width : 1220 px ; margin : 0 auto }
. lede { font-family : var ( - - serif ) ; font-size : 21 px ; line-height : 1.45 ; color : var ( - - navy ) ;
margin : 0 0 6 px ; max-width : 860 px }
. lede b { color : var ( - - gold ) }
. sublede { font-size : 13 px ; color : var ( - - muted ) ; margin : 0 0 22 px ; max-width : 860 px ; line-height : 1.55 }
. kpis { display : flex ; gap : 26 px ; flex-wrap : wrap ; padding : 16 px 0 20 px ;
border-top : 1 px solid var ( - - rule ) ; border-bottom : 1 px solid var ( - - rule ) ; margin-bottom : 24 px }
. kpi b { display : block ; font-family : var ( - - serif ) ; font-size : 29 px ; color : var ( - - navy ) ; line-height : 1 }
. kpi . warn b { color : var ( - - gold ) }
. kpi span { font-size : 9.5 px ; text-transform : uppercase ; letter-spacing : .1 em ; color : var ( - - muted ) ;
display : block ; margin-top : 5 px }
h2 . sec { font-family : var ( - - mono ) ; font-size : 10 px ; letter-spacing : .15 em ; text-transform : uppercase ;
color : var ( - - muted ) ; margin : 30 px 0 12 px ; font-weight : 600 }
. grid { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( 280 px , 1 fr ) ) ; gap : 14 px }
. card { background : var ( - - panel ) ; border : 1 px solid var ( - - rule ) ; border-radius : 4 px ;
padding : 0 ; overflow : hidden ; cursor : pointer ; transition : box-shadow .15 s }
. card : hover { box-shadow : 0 3 px 14 px rgba ( 10 , 31 , 68 , .10 ) }
. card . top { height : 4 px }
. card . body { padding : 15 px 17 px 16 px }
. card . role { font-family : var ( - - mono ) ; font-size : 8.5 px ; letter-spacing : .13 em ;
2026-07-26 21:28:14 +02:00
text-transform : uppercase ; font-weight : 600 }
2026-07-29 09:03:59 +02:00
. card h3 { margin : 5 px 0 2 px ; font-family : var ( - - serif ) ; font-size : 19 px ; color : var ( - - navy ) }
. card . who { font-size : 11.5 px ; color : var ( - - muted ) ; margin-bottom : 12 px }
. card . nums { display : flex ; gap : 15 px ; flex-wrap : wrap ; padding-top : 11 px ; border-top : 1 px solid var ( - - rule ) }
. card . nums div { font-size : 10.5 px ; color : var ( - - muted ) }
. card . nums b { display : block ; font-family : var ( - - mono ) ; font-size : 15 px ; color : var ( - - ink ) }
. flag { display : inline-block ; margin-top : 11 px ; padding : 4 px 9 px ; border-radius : 3 px ;
background : #FBF3E4 ; border : 1 px solid var ( - - gold ) ; color : #7A5B2A ;
font-family : var ( - - mono ) ; font-size : 9.5 px ; letter-spacing : .05 em }
table { width : 100 % ; border-collapse : collapse ; background : var ( - - panel ) ; font-size : 12.5 px }
th { text-align : left ; padding : 9 px 11 px ; font-family : var ( - - mono ) ; font-size : 9 px ;
letter-spacing : .1 em ; text-transform : uppercase ; color : var ( - - muted ) ;
border-bottom : 1.5 px solid var ( - - navy ) ; white-space : nowrap ; background : var ( - - panel ) ;
position : sticky ; top : 0 ; cursor : pointer }
td { padding : 8 px 11 px ; border-bottom : 1 px solid var ( - - rule ) ; vertical-align : top }
tr . arb td { background : #FDFAF3 }
tr : hover td { background : var ( - - soft ) }
tr { cursor : pointer }
. dot { display : inline-block ; width : 8 px ; height : 8 px ; border-radius : 2 px ; margin-right : 7 px ;
vertical-align : baseline }
. mono { font-family : var ( - - mono ) ; font-size : 10.5 px ; color : var ( - - muted ) }
. pill { display : inline-block ; padding : 2 px 7 px ; border-radius : 2 px ; font-family : var ( - - mono ) ;
font-size : 8.5 px ; letter-spacing : .06 em ; text-transform : uppercase ; border : 1 px solid }
. bar { display : flex ; height : 26 px ; border-radius : 3 px ; overflow : hidden ; margin : 6 px 0 4 px }
. bar div { position : relative }
. legend { display : flex ; gap : 16 px ; flex-wrap : wrap ; font-size : 11 px ; color : var ( - - muted ) ; margin-bottom : 20 px }
. legend i { display : inline-block ; width : 10 px ; height : 10 px ; border-radius : 2 px ; margin-right : 6 px }
. filters { display : flex ; gap : 8 px ; flex-wrap : wrap ; margin-bottom : 14 px ; align-items : center }
. filters input { padding : 7 px 11 px ; border : 1 px solid var ( - - rule ) ; border-radius : 3 px ;
font-family : var ( - - mono ) ; font-size : 12 px ; min-width : 180 px ; background : #fff }
. filters button { padding : 6 px 12 px ; border : 1 px solid var ( - - rule ) ; background : #fff ;
border-radius : 3 px ; font-family : var ( - - mono ) ; font-size : 10 px ; cursor : pointer ;
text-transform : uppercase ; letter-spacing : .07 em ; color : var ( - - muted ) }
. filters button . on { background : var ( - - navy ) ; color : #fff ; border-color : var ( - - navy ) }
# insp { position : fixed ; top : 0 ; right : 0 ; width : 390 px ; max-width : 100 % ; height : 100 % ;
2026-07-26 21:28:14 +02:00
background : var ( - - panel ) ; border-left : 1 px solid var ( - - rule ) ;
2026-07-29 09:03:59 +02:00
box-shadow : -8 px 0 30 px rgba ( 10 , 31 , 68 , .13 ) ; overflow-y : auto ; padding : 22 px 22 px 60 px ;
transform : translateX ( 100 % ) ; transition : transform .22 s ; z-index : 50 }
2026-07-26 21:28:14 +02:00
# insp . open { transform : translateX ( 0 ) }
2026-07-29 09:03:59 +02:00
# insp . x { position : absolute ; top : 13 px ; right : 15 px ; border : none ; background : none ;
font-size : 23 px ; cursor : pointer ; color : var ( - - muted ) ; line-height : 1 }
. inh { font-style : italic ; color : #8A6A28 ; font-size : 9 px ; text-transform : uppercase ;
letter-spacing : .06 em ; font-family : var ( - - mono ) }
. backlink { background : none ; border : none ; padding : 0 0 10 px ; font-family : var ( - - mono ) ;
font-size : 10 px ; color : var ( - - navy ) ; cursor : pointer ; letter-spacing : .05 em }
. kind { font-family : var ( - - mono ) ; font-size : 9 px ; letter-spacing : .14 em ;
2026-07-26 21:28:14 +02:00
text-transform : uppercase ; font-weight : 600 }
2026-07-29 09:03:59 +02:00
. gctl . gsec { font-family : var ( - - mono ) ; font-size : 8.5 px ; letter-spacing : .12 em ;
text-transform : uppercase ; color : var ( - - muted ) ; margin : 9 px 0 4 px ; font-weight : 600 }
. gctl select { width : 100 % ; padding : 4 px ; font-family : var ( - - mono ) ; font-size : 10 px ;
border : 1 px solid var ( - - rule ) ; border-radius : 3 px ; margin-top : 5 px }
. lanelabel { font-family : var ( - - mono ) ; font-size : 10 px ; letter-spacing : .1 em ; font-weight : 600 }
. elabel { font-family : var ( - - mono ) ; font-size : 7.5 px ; fill : #5A6270 ; text-anchor : middle ;
pointer-events : none }
. elbg { fill : #F4F2EC ; pointer-events : none }
. elabel . dim , . elbg . dim { opacity : .06 }
# insp h3 { margin : 7 px 0 2 px ; font-family : var ( - - serif ) ; font-size : 21 px ; color : var ( - - navy ) }
# insp . iri { font-family : var ( - - mono ) ; font-size : 10.5 px ; color : var ( - - muted ) ; margin-bottom : 15 px }
# insp h4 { font-family : var ( - - mono ) ; font-size : 9 px ; letter-spacing : .14 em ; text-transform : uppercase ;
color : var ( - - muted ) ; margin : 19 px 0 7 px ; border-top : 1 px solid var ( - - rule ) ; padding-top : 11 px }
# insp p { font-size : 12.5 px ; line-height : 1.6 ; margin : 0 0 8 px }
. row { display : flex ; justify-content : space-between ; gap : 12 px ; padding : 5 px 0 ;
border-bottom : 1 px dotted var ( - - rule ) ; font-size : 12 px }
. row span : last-child { color : var ( - - muted ) ; text-align : right }
2026-07-26 21:28:14 +02:00
. jump { background : none ; border : none ; padding : 0 ; font : inherit ; color : var ( - - navy ) ;
2026-07-29 09:03:59 +02:00
cursor : pointer ; text-decoration : underline ; text-underline-offset : 2 px ; font-size : 12 px }
. note { background : #FBF3E4 ; border-left : 3 px solid var ( - - gold ) ; padding : 10 px 12 px ;
font-size : 11.5 px ; line-height : 1.55 ; color : #6B5426 }
/* ---- graph ---- */
. gwrap { position : relative ; height : calc ( 100 vh - 250 px ) ; min-height : 420 px ;
border : 1 px solid var ( - - rule ) ; border-radius : 4 px ; overflow : hidden ; background :
linear-gradient ( 90 deg , rgba ( 10 , 31 , 68 , .03 ) 1 px , transparent 1 px ) 0 0 / 26 px 26 px ,
linear-gradient ( 180 deg , #FCFBF7 , #F0EEE5 ) ; touch-action : none }
. gwrap svg { display : block ; width : 100 % ; height : 100 % ; cursor : grab }
. gwrap svg . drag { cursor : grabbing }
. bandrect { opacity : .5 }
. bandlabel { font-family : var ( - - mono ) ; font-size : 9.5 px ; letter-spacing : .14 em ;
text-transform : uppercase ; font-weight : 600 }
. bandtag { font-size : 9 px ; fill : var ( - - muted ) ; font-style : italic }
. gnode { cursor : pointer }
. gnode rect { stroke-width : 1.4 }
. gnode text { font-size : 9 px ; text-anchor : middle ; pointer-events : none ; font-weight : 500 }
. gnode . dim { opacity : .11 }
. gnode . sel rect { stroke-width : 3 }
. gedge { fill : none ; stroke : #A6ABB5 ; stroke-width : 1 ; marker-end : url ( #ga ) }
. gedge . cross { stroke : #B8935A ; stroke-width : 1.8 ; marker-end : url ( #gg ) }
. gedge . dim { opacity : .05 }
. gedge . hot { stroke : var ( - - navy ) ; stroke-width : 2.4 ; marker-end : url ( #gn ) }
. gctl { position : absolute ; left : 12 px ; top : 12 px ; background : rgba ( 255 , 255 , 255 , .94 ) ;
border : 1 px solid var ( - - rule ) ; border-radius : 4 px ; padding : 9 px 11 px ; font-size : 11 px ;
max-width : 230 px }
. gctl . seg { display : flex ; border : 1 px solid var ( - - rule ) ; border-radius : 3 px ; overflow : hidden ;
margin-bottom : 8 px }
. gctl . seg button { flex : 1 ; padding : 5 px ; border : none ; background : #fff ; cursor : pointer ;
font-family : var ( - - mono ) ; font-size : 9 px ; text-transform : uppercase ; letter-spacing : .06 em ;
color : var ( - - muted ) }
. gctl . seg button + button { border-left : 1 px solid var ( - - rule ) }
. gctl . seg button . on { background : var ( - - navy ) ; color : #fff }
. gctl label { display : flex ; align-items : center ; gap : 7 px ; padding : 3 px 0 ; cursor : pointer }
. gctl label i { display : inline-block ; width : 8 px ; height : 8 px ; border-radius : 2 px ; flex-shrink : 0 }
. gctl input { accent-color : var ( - - navy ) }
. gzoom { position : absolute ; right : 12 px ; bottom : 12 px ; display : flex ; gap : 3 px ;
background : #fff ; border : 1 px solid var ( - - rule ) ; border-radius : 4 px ; padding : 3 px }
. gzoom button { width : 30 px ; height : 30 px ; border : none ; background : #fff ; cursor : pointer ;
font-size : 15 px ; color : var ( - - navy ) ; border-radius : 3 px }
. ghint { position : absolute ; left : 12 px ; bottom : 12 px ; font-family : var ( - - mono ) ; font-size : 9 px ;
color : var ( - - muted ) ; background : rgba ( 255 , 255 , 255 , .85 ) ; padding : 4 px 8 px ; border-radius : 3 px }
footer { background : var ( - - navy ) ; color : rgba ( 255 , 255 , 255 , .5 ) ; padding : 7 px 20 px ;
font-family : var ( - - mono ) ; font-size : 9 px ; letter-spacing : .06 em ; flex-shrink : 0 ;
display : flex ; justify-content : space-between }
@ media ( max-width : 760px ) {
. tabs { width : 100 % ; margin : 8 px 0 0 } header { flex-wrap : wrap }
. kpis { gap : 18 px } . kpi b { font-size : 23 px }
table { font-size : 11.5 px } td , th { padding : 7 px 8 px }
. hide-s { display : none }
2026-07-26 21:28:14 +02:00
}
@ media ( prefers-reduced-motion : reduce ) { * { transition : none !important } }
< / style >
< / head >
< body >
< header >
< div >
< h1 > PR Data Meta Model · T-Box< / h1 >
2026-07-29 09:03:59 +02:00
< div class = "sub" > Six layers, crossed with what governance asserts and what a harvester observes< / div >
2026-07-26 21:28:14 +02:00
< / div >
2026-07-29 09:03:59 +02:00
< div class = "tabs" role = "tablist" >
< button id = "t-scope" class = "on" > Overview< / button >
< button id = "t-model" > Layers< / button >
< button id = "t-graph" > Graph< / button >
< button id = "t-props" > Relations< / button >
< button id = "t-dep" > Deprecated< / button >
2026-07-26 21:28:14 +02:00
< / div >
< / header >
2026-07-29 09:03:59 +02:00
< main > < div class = "wrap" id = "view" > < / div > < / main >
< div id = "insp" role = "dialog" aria-label = "Details" >
< button class = "x" id = "insp-x" aria-label = "Close" > × < / button >
< div id = "insp-body" > < / div >
< / div >
2026-07-26 21:28:14 +02:00
< footer >
< span > Build artefact of pr_metamodel.ttl — regenerate, never edit< / span >
2026-07-29 09:03:59 +02:00
< span id = "f-count" > < / span >
2026-07-26 21:28:14 +02:00
< / footer >
< script >
2026-07-29 09:03:59 +02:00
var M = { "nodes" : [ { "id" : "pr:MetaModelObject" , "kind" : "Class" , "label" : "Meta Model Object" , "layer" : "roots" , "prov" : "structural" , "depth" : 0 , "parent" : "" , "comment" : "Anything the meta model describes. Actors are deliberately NOT Meta Model Objects." , "harvest" : "" , "curation" : "" , "acronym" : "" , "deprecated" : false , "replacedBy" : "" , "abstract" : false , "members" : [ ] } , { "id" : "pr:CuratedObject" , "kind" : "Class" , "label" : "Curated Object" , "layer" : "roots" , "prov" : "curated" , "depth" : 1 , "parent" : "pr:MetaModelObject" , "comment" : "Asserted by governance, edited through pull request on the domain TTL file, subject to the full ownership and lifecycle shape (identifier, name, owner, owning Data Domain, status, version)." , "harvest" : "" , "curation" : "ASSERTED" , "acronym" : "" , "deprecated" : false , "replacedBy" : "" , "abstract" : true , "members" : [ ] } , { "id" : "pr:ObservedObject" , "kind" : "Class" , "label" : "Observed Object" , "layer" : "roots" , "prov" : "observed" , "depth" : 1 , "parent" : "pr:MetaModelObject" , "comment" : "Harvested from a running system and rebuilt on every run. Never hand-edited. Carries sourceIdentifier, fullyQualifiedName and harvestedOn instead of curated ownership: attribution is INFERRED through the Data Element it is mapped to, not asserted on the node." , "harvest" : "" , "curation" : "HARVESTED" , "acronym" : "" , "deprecated" : false , "replacedBy" : "" , "abstract" : true , "members" : [ ] } , { "id" : "pr:OwnershipLayerObject" , "kind" : "Class" , "label" : "Ownership & Categorization Layer Object" , "layer" : "ownership" , "prov" : "curated" , "depth" : 2 , "parent" : "pr:CuratedObject" , "comment" : "" , "harvest" : "" , "curation" : "" , "acronym" : "" , "deprecated" : false , "replacedBy" : "" , "abstract" : false , "members" : [ ] } , { "id" : "pr:BusinessLayerObject" , "kind" : "Class" , "label" : "Business Layer Object" , "layer" : "business" , "prov" : "curated" , "depth" : 2 , "parent" : "pr:CuratedObject" , "comment" : "" , "harvest" : "" , "curation" : "" , "acronym" : "" , "deprecated" : false , "replacedBy" : "" , "abstract" : false , "members" : [ ] } , { "id" : "pr:LogicalLayerObject" , "kind" : "Class" , "label" : "Logical Layer Object" , "layer" : "logical" , "prov" : "curated" , "depth" : 2 , "parent" : "pr:CuratedObject" , "comment" : "How the data is structured." , "harvest" : "" , "curation" : "" , "acronym" : "" , "deprecated" : false , "replacedBy" : "" , "abstract" : false , "members" : [ ] } , { "id" : "pr:DeliveryLayerObject" , "kind" : "Class" , "label" : "Delivery Layer Object" , "layer" : "delivery" , "prov" : "curated" , "depth" : 2 , "parent" : "pr:CuratedObject" , "comment" : "What we promise, to whom, until when. A different question from how the data is structured, hence a layer of its own: version, owner, changelog, support channel, consumers to warn." , "harvest" : "" , "curation" : "" , "acronym" : "" , "deprecated" : false , "replacedBy" : "" , "abstract" : false , "members" : [ ] } , { "id" : "pr:PhysicalLayerObject" , "kind" : "Class" , "label" : "Physical Layer Object" , "layer" : "physical" , "prov" : "observed" , "depth" : 2 , "parent" : "pr:ObservedObject" , "comment" : "" , "harvest" : "" , "curation" : "" , "acronym" : "" , "deprecated" : false , "replacedBy" : "" , "abstract" : false , "members" : [ ] } , { "id" : "pr:ConsumptionLayerObject" , "kind" : "Class" , "label" : "Consumption Layer Object" , "layer" : "consumption" , "prov" : "observed" , "depth" : 2 , "parent" : "pr:ObservedObject" , "comment" : "The BI estate. Separate from Physical because BI assets have their own lifecycle and their own owner: a dashboard belongs to its author, not to the Data Domain that owns the underlying data." , "harvest" : "" , "curation" : "" , "acronym" : "" , "deprecated" : false , "replacedBy" : "" , "abstract" : false , "members" : [ ] } , { "id" : "pr:DataDomain" , "kind" : "Class" , "label" : "Data Domain" , "layer" : "ownership" , "prov" : "curated" , "depth" : 3 , "parent" : "pr:OwnershipLayerObject" , "comment" : "" , "harvest" : "" , "curation" : "" , "acronym" : "" , "deprecated" : false , "replacedBy" : "" , "abstract" : false , "members" : [ ] } , { "id" : "pr:SubDomain" , "kind" : "Class" , "label" : "Sub-Domain" , "layer" : "ownership" , "prov" : "curated" , "depth" : 3 , "parent" : "pr:OwnershipLayerObject" , "comment" : "" , "harvest" : "" , "curation" : "" , "acronym" : "" , "deprecated" : false , "replacedBy" : "" , "abstract" : false , "members" : [ ] } , { "id" : "pr:BusinessObject" , "kind" : "Class" , "label" : "Business Object" , "layer" : "ownership" , "prov" : "curated" , "depth" : 3 , "parent" : "pr:OwnershipLayerObject" , "comment" : "The single anchor of accountability,
var byId = {};
M.nodes.forEach(function(n){ byId[n.id] = n; });
M.relations.concat(M.attributes, M.bridges).forEach(function(p){ byId[p.id] = p; });
var view = " scope ", filt = {q:" ", kind:" "};
function esc(s){ return (s||" ").replace(/&/g," & amp ; ").replace(/</g," & lt ; "); }
function clean(s){ return (s||" ").replace(" ( deprecated ) "," "); }
document.getElementById(" f - count ").textContent =
M.nodes.filter(function(n){return !n.deprecated;}).length + " classes \u00b7 "
+ M.relations.filter(function(r){return !r.deprecated;}).length + " relations ";
/* ============================================================ GRAPH */
var G = {mode:" strata ", provs:[], sel:null, hot:null, labels:true,
layers:{},
view:{x:0,y:0,s:1}, nodes:[], edges:[]};
var NW=126, NH=32, LANEPAD=26, ROWGAP=15, BANDGAP=34, GUT=142;
function provColour(p){
return p === " observed " ? " # 8 A6A28 " : p === " curated " ? " # 1E4 B9B " : " # 6 B6B6B ";
}
var LANES = [[" curated "," Curated \u00b7 asserted by governance "," # 1E4 B9B "],
[" observed "," Observed \u00b7 harvested nightly "," # 8 A6A28 "],
[" structural "," Structural "," # 6 B6B6B "]];
function live(a){ return a.filter(function(x){ return !x.deprecated; }); }
function layerTitle(id){
var l = M.layers.filter(function(x){ return x.id === id; })[0];
return l ? l.title : id;
}
function buildGraph(){
var keep = {};
live(M.nodes).forEach(function(n){
if(!G.layers[n.layer]) return;
if(G.provs.length && G.provs.indexOf(n.prov) === -1) return;
keep[n.id] = {n:n, x:0, y:0,
layer: M.layers.map(function(l){return l.id;}).indexOf(n.layer),
row: Math.min(n.depth, 2)};
});
var E = [];
live(M.relations).forEach(function(r){
if(keep[r.domain] && keep[r.range] && r.domain !== r.range)
E.push({s:r.domain, t:r.range, l:r.label, cross:r.seam});
2026-07-26 21:28:14 +02:00
});
2026-07-29 09:03:59 +02:00
live(M.nodes).forEach(function(n){
if(n.parent && keep[n.id] && keep[n.parent])
E.push({s:n.id, t:n.parent, l:" specialises ", cross:false});
});
G.nodes = Object.keys(keep).map(function(k){ return keep[k]; });
G.edges = E;
return keep;
2026-07-26 21:28:14 +02:00
}
2026-07-29 09:03:59 +02:00
/* Strata = a matrix. Columns are domains, rows are meta model layers.
Everything owned by one domain sits in one vertical lane, so a cross-domain
dependency is literally a line leaving its column. */
function layoutGraph(idx){
if(G.mode === " network "){ layoutForce(idx); return; }
var lanes = LANES.map(function(L){ return L[0]; })
.filter(function(c){ return G.nodes.some(function(n){ return n.n.prov === c; }); });
var laneW = {}, x = GUT;
G._lanes = [];
lanes.forEach(function(code){
var members = G.nodes.filter(function(n){ return n.n.prov === code; });
var widest = 1;
M.layers.forEach(function(_, li){
[0,1,2].forEach(function(ri){
var c = members.filter(function(n){ return n.layer===li && n.row===ri; }).length;
if(c) widest = Math.max(widest, Math.min(c, Math.ceil(Math.sqrt(c*1.6))));
});
});
var w = widest*(NW+12) + LANEPAD*2;
laneW[code] = {x:x, w:w, per:widest};
var L = LANES.filter(function(z){ return z[0] === code; })[0];
G._lanes.push({code:code, x:x, w:w, label:L[1], colour:L[2]});
x += w;
2026-07-26 21:28:14 +02:00
});
2026-07-29 09:03:59 +02:00
G._width = x;
var y = 0;
M.layers.forEach(function(b, li){
b._y = y;
var maxRows = 0;
var rowTops = {};
var cursor = y + 34;
[0,1,2].forEach(function(ri){
var any = G.nodes.filter(function(n){ return n.layer===li && n.row===ri; });
if(!any.length){ rowTops[ri] = cursor; return; }
rowTops[ri] = cursor;
var deepest = 0;
lanes.forEach(function(code){
var g = any.filter(function(n){ return n.n.prov === code; });
if(!g.length) return;
var per = laneW[code].per;
deepest = Math.max(deepest, Math.ceil(g.length/per));
g.sort(function(p,q){ return p.n.label.localeCompare(q.n.label); });
g.forEach(function(n, i){
n.x = laneW[code].x + LANEPAD + (i%per)*(NW+12) + NW/2;
n.y = cursor + Math.floor(i/per)*(NH+ROWGAP) + NH/2;
});
});
cursor += deepest*(NH+ROWGAP) + ROWGAP;
maxRows += deepest;
2026-07-26 21:28:14 +02:00
});
2026-07-29 09:03:59 +02:00
b._h = maxRows ? (cursor - y + 12) : 0;
y += b._h + (b._h ? BANDGAP : 0);
});
G._height = y;
2026-07-26 21:28:14 +02:00
}
2026-07-29 09:03:59 +02:00
function layoutForce(idx){
var links = G.edges.filter(function(e){ return idx[e.s] && idx[e.t]; });
G.nodes.forEach(function(n, i){
var a = i*2.399963;
n.x = 600 + Math.sqrt(i)*46*Math.cos(a);
n.y = 400 + Math.sqrt(i)*46*Math.sin(a);
});
var iters = G.nodes.length > 90 ? 200 : 380;
for(var it=0; it<iters; it++){
var k = 1 - it/iters;
for(var i=0; i<G.nodes.length; i++){
var a = G.nodes[i];
for(var j=i+1; j<G.nodes.length; j++){
var b = G.nodes[j], dx = b.x-a.x, dy = (b.y-a.y)*1.7;
var d2 = dx*dx+dy*dy || 1, f = Math.min(30000/d2, 8), d = Math.sqrt(d2);
a.x -= dx/d*f; a.y -= dy/d*f*.6; b.x += dx/d*f; b.y += dy/d*f*.6;
2026-07-26 21:28:14 +02:00
}
2026-07-29 09:03:59 +02:00
a.x += (600-a.x)*.003*k; a.y += (400-a.y)*.004*k;
2026-07-26 21:28:14 +02:00
}
links.forEach(function(e){
2026-07-29 09:03:59 +02:00
var a = idx[e.s], b = idx[e.t], dx = b.x-a.x, dy = b.y-a.y;
var d = Math.sqrt(dx*dx+dy*dy) || 1, f = (d-190)*.02*k;
a.x += dx/d*f; a.y += dy/d*f; b.x -= dx/d*f; b.y -= dy/d*f;
2026-07-26 21:28:14 +02:00
});
}
2026-07-29 09:03:59 +02:00
for(var pass=0; pass<40; pass++){
var moved = 0;
for(var i=0; i<G.nodes.length; i++){
for(var j=i+1; j<G.nodes.length; j++){
var a = G.nodes[i], b = G.nodes[j];
var ox = (NW+10)-Math.abs(b.x-a.x), oy = (NH+8)-Math.abs(b.y-a.y);
if(ox>0 && oy>0){
moved++;
if(ox<oy){ var s=(b.x>=a.x?1:-1)*ox/2; a.x-=s; b.x+=s; }
else { var t=(b.y>=a.y?1:-1)*oy/2; a.y-=t; b.y+=t; }
}
}
}
if(!moved) break;
2026-07-26 21:28:14 +02:00
}
}
2026-07-29 09:03:59 +02:00
function renderGraph(){
var idx = buildGraph();
G.nodes.forEach(function(n){ idx[n.n.id] = n; });
layoutGraph(idx);
var bL=document.getElementById(" g - b "), eL=document.getElementById(" g - e "),
lL=document.getElementById(" g - l "), nL=document.getElementById(" g - n ");
bL.innerHTML=eL.innerHTML=lL.innerHTML=nL.innerHTML=" ";
var NS=" http : //www.w3.org/2000/svg";
function el ( t , a ) { var e = document . createElementNS ( NS , t ) ;
for ( var k in a ) e . setAttribute ( k , a [ k ] ) ; return e ; }
if ( G . mode === "strata" && G . nodes . length ) {
/* domain lanes: alternating tint, so a column reads as one domain */
( G . _lanes || [ ] ) . forEach ( function ( l , i ) {
var isSubject = l . code === "curated" ;
bL . appendChild ( el ( "rect" , { x : l . x , y : - 6 , width : l . w , height : G . _height + 10 ,
fill : l . colour , opacity : isSubject ? . 05 : . 028 } ) ) ;
if ( i ) bL . appendChild ( el ( "line" , { x1 : l . x , y1 : - 6 , x2 : l . x , y2 : G . _height + 4 ,
stroke : "#D8D4C8" , "stroke-width" : 1 } ) ) ;
var t = el ( "text" , { x : l . x + l . w / 2 , y : - 16 , "text-anchor" : "middle" ,
class : "lanelabel" , fill : l . colour } ) ;
t . textContent = l . label ;
bL . appendChild ( t ) ;
} ) ;
M . layers . forEach ( function ( b ) {
if ( ! b . _h ) return ;
bL . appendChild ( el ( "line" , { x1 : 0 , y1 : b . _y , x2 : ( G . _width || 900 ) + 10 , y2 : b . _y ,
stroke : "#CFCABA" , "stroke-width" : 1 } ) ) ;
var t = el ( "text" , { x : GUT - 16 , y : b . _y + 18 , class : "bandlabel" , "text-anchor" : "end" ,
fill : "#0A1F44" } ) ; t . textContent = b . title ; bL . appendChild ( t ) ;
var g = el ( "text" , { x : GUT - 16 , y : b . _y + 31 , class : "bandtag" , "text-anchor" : "end" } ) ;
2026-07-26 21:28:14 +02:00
g . textContent = b . tag ; bL . appendChild ( g ) ;
} ) ;
}
2026-07-29 09:03:59 +02:00
/* clip both ends to the node rectangle: an arrow head drawn to the centre
disappears under the box, which is what hid every marker until now */
function clip ( from , to ) {
var dx = to . x - from . x , dy = to . y - from . y ;
if ( ! dx && ! dy ) return { x : to . x , y : to . y } ;
var sx = ( NW / 2 + 5 ) / Math . abs ( dx || 1e-6 ) , sy = ( NH / 2 + 5 ) / Math . abs ( dy || 1e-6 ) ;
var t = Math . min ( sx , sy , 1 ) ;
return { x : to . x - dx * t , y : to . y - dy * t } ;
}
G . edges . forEach ( function ( e ) {
var a = idx [ e . s ] , b = idx [ e . t ] ; if ( ! a || ! b ) return ;
var dx = b . x - a . x , dy = b . y - a . y ;
var cx = a . x + dx / 2 + ( - dy ) * . 09 , cy = a . y + dy / 2 + dx * . 035 ;
var p0 = clip ( b , a ) , p1 = clip ( a , b ) ;
var p = el ( "path" , { d : "M" + p0 . x + "," + p0 . y + " Q" + cx + "," + cy + " " + p1 . x + "," + p1 . y ,
class : "gedge" + ( e . cross ? " cross" : "" ) } ) ;
p . dataset . a = e . s ; p . dataset . b = e . t ; eL . appendChild ( p ) ;
if ( G . labels ) {
var mx = ( a . x + 2 * cx + b . x ) / 4 , my = ( a . y + 2 * cy + b . y ) / 4 ;
var w = e . l . length * 4.3 + 7 ;
var bg = el ( "rect" , { x : mx - w / 2 , y : my - 6 , width : w , height : 11 , rx : 2 , class : "elbg" } ) ;
bg . dataset . a = e . s ; bg . dataset . b = e . t ; lL . appendChild ( bg ) ;
var tx = el ( "text" , { x : mx , y : my + 2.4 , class : "elabel" } ) ;
tx . dataset . a = e . s ; tx . dataset . b = e . t ; tx . textContent = e . l ; lL . appendChild ( tx ) ;
2026-07-26 21:28:14 +02:00
}
} ) ;
2026-07-29 09:03:59 +02:00
G . nodes . forEach ( function ( n ) {
var c = provColour ( n . n . prov ) ;
var g = el ( "g" , { class : "gnode" , tabindex : "0" , role : "button" ,
transform : "translate(" + ( n . x - NW / 2 ) + "," + ( n . y - NH / 2 ) + ")" } ) ;
g . dataset . id = n . n . id ;
/* dashed outline = abstract: a class that exists to give its
specialisations a shape, never instantiated */
g . appendChild ( el ( "rect" , { width : NW , height : NH , rx : 3 , fill : "#FFFFFF" ,
stroke : c , "stroke-dasharray" : n . n . abstract ? "5 3" : "none" } ) ) ;
var lines = wrapText ( n . n . label , 21 ) ;
lines . forEach ( function ( t , i ) {
var e = el ( "text" , { x : NW / 2 , y : NH / 2 + 3.5 + ( i - ( lines . length - 1 ) / 2 ) * 10 , fill : "#1A1A1A" } ) ;
e . textContent = t ; g . appendChild ( e ) ;
2026-07-26 21:28:14 +02:00
} ) ;
nL . appendChild ( g ) ;
} ) ;
applyHot ( ) ;
2026-07-29 09:03:59 +02:00
fitGraph ( ) ;
var cd = G . edges . filter ( function ( e ) { return e . cross ; } ) . length ;
document . getElementById ( "g-count" ) . textContent =
G . nodes . length + " classes \u00b7 " + G . edges . length + " edges \u00b7 " + cd + " seam" ;
2026-07-26 21:28:14 +02:00
}
2026-07-29 09:03:59 +02:00
function wrapText ( s , max ) {
var w = s . split ( " " ) , out = [ ] , cur = "" ;
2026-07-26 21:28:14 +02:00
w . forEach ( function ( x ) {
2026-07-29 09:03:59 +02:00
if ( ( cur + " " + x ) . trim ( ) . length > max ) { if ( cur ) out . push ( cur ) ; cur = x ; }
else cur = ( cur + " " + x ) . trim ( ) ; } ) ;
if ( cur ) out . push ( cur ) ;
return out . slice ( 0 , 2 ) ;
2026-07-26 21:28:14 +02:00
}
function applyHot ( ) {
2026-07-29 09:03:59 +02:00
var id = G . sel || G . hot ;
var ns = document . querySelectorAll ( ".gnode" ) , es = document . querySelectorAll ( ".gedge" ) ,
ls = document . querySelectorAll ( ".elabel,.elbg" ) ;
2026-07-26 21:28:14 +02:00
if ( ! id ) {
2026-07-29 09:03:59 +02:00
ns . forEach ( function ( n ) { n . classList . remove ( "dim" , "sel" ) ; } ) ;
es . forEach ( function ( e ) { e . classList . remove ( "dim" , "hot" ) ; } ) ;
ls . forEach ( function ( l ) { l . classList . remove ( "dim" ) ; } ) ;
2026-07-26 21:28:14 +02:00
return ;
}
2026-07-29 09:03:59 +02:00
var keep = { } ; keep [ id ] = 1 ;
G . edges . forEach ( function ( e ) {
if ( e . s === id ) keep [ e . t ] = 1 ; if ( e . t === id ) keep [ e . s ] = 1 ; } ) ;
es . forEach ( function ( p ) {
var on = p . dataset . a === id || p . dataset . b === id ;
p . classList . toggle ( "hot" , on ) ; p . classList . toggle ( "dim" , ! on ) ; } ) ;
ls . forEach ( function ( l ) {
l . classList . toggle ( "dim" , ! ( l . dataset . a === id || l . dataset . b === id ) ) ; } ) ;
ns . forEach ( function ( n ) {
n . classList . toggle ( "dim" , ! keep [ n . dataset . id ] ) ;
n . classList . toggle ( "sel" , n . dataset . id === G . sel ) ; } ) ;
}
function clearSel ( ) { G . sel = null ; G . hot = null ; applyHot ( ) ; }
function applyView ( ) {
document . getElementById ( "g-vp" ) . setAttribute ( "transform" ,
"translate(" + G . view . x + "," + G . view . y + ") scale(" + G . view . s + ")" ) ;
}
function fitGraph ( ) {
if ( ! G . nodes . length ) return ;
var svg = document . getElementById ( "g-svg" ) , r = svg . getBoundingClientRect ( ) ;
var xs = G . nodes . map ( function ( n ) { return n . x ; } ) , ys = G . nodes . map ( function ( n ) { return n . y ; } ) ;
var pad = G . mode === "strata" ? GUT + 20 : NW ;
var mnx = Math . min . apply ( null , xs ) - pad , mxx = Math . max . apply ( null , xs ) + NW ;
var mny = Math . min . apply ( null , ys ) - ( G . mode === "strata" ? 66 : 60 ) , mxy = Math . max . apply ( null , ys ) + 50 ;
G . view . s = Math . min ( r . width / ( mxx - mnx ) , r . height / ( mxy - mny ) , 1.15 ) ;
G . view . x = ( r . width - ( mxx - mnx ) * G . view . s ) / 2 - mnx * G . view . s ;
G . view . y = ( r . height - ( mxy - mny ) * G . view . s ) / 2 - mny * G . view . s ;
applyView ( ) ;
}
function zoomG ( f ) {
var svg = document . getElementById ( "g-svg" ) , r = svg . getBoundingClientRect ( ) ;
var cx = r . width / 2 , cy = r . height / 2 ;
var ns = Math . max ( . 1 , Math . min ( G . view . s * f , 3 ) ) ;
G . view . x = cx - ( cx - G . view . x ) * ( ns / G . view . s ) ;
G . view . y = cy - ( cy - G . view . y ) * ( ns / G . view . s ) ;
G . view . s = ns ; applyView ( ) ;
}
/* ------------------------------------------------------------ OVERVIEW */
function kpi ( v , l , w ) { return '<div class="kpi' + ( w ? ' warn' : '' ) + '"><b>' + v + '</b><span>' + l + '</span></div>' ; }
function renderScope ( ) {
var cls = live ( M . nodes ) , rel = live ( M . relations ) , att = live ( M . attributes ) ;
var cur = cls . filter ( function ( n ) { return n . prov === "curated" ; } ) ;
var obs = cls . filter ( function ( n ) { return n . prov === "observed" ; } ) ;
var str = cls . filter ( function ( n ) { return n . prov === "structural" ; } ) ;
var seam = rel . filter ( function ( r ) { return r . seam ; } ) ;
var dep = M . nodes . filter ( function ( n ) { return n . deprecated ; } )
. concat ( M . relations . concat ( M . attributes ) . filter ( function ( r ) { return r . deprecated ; } ) ) ;
var h = [ ] ;
h . push ( '<p class="lede">The layer says what an object <b>is</b>. The provenance says <b>how it got here</b>.</p>' ) ;
h . push ( '<p class="sublede">Curated objects are asserted by governance and reviewed through a pull request. '
+ 'Observed objects are harvested from Snowflake, Tableau and Looker, rebuilt on every run and never edited '
+ 'by hand. Without that split the ownership rules would demand an owner, a status and a version on every '
+ 'one of some fifty thousand warehouse columns.</p>' ) ;
h . push ( '<div class="kpis">' + kpi ( cls . length , "Classes" ) + kpi ( rel . length , "Relations" )
+ kpi ( att . length , "Attributes" ) + kpi ( M . bridges . length , "Bridge properties" )
+ kpi ( seam . length , "Seam edges" , true ) + kpi ( dep . length , "Deprecated" , true ) + '</div>' ) ;
h . push ( '<h2 class="sec">Curated against observed</h2><div class="bar">'
+ '<div style="width:' + ( cur . length / cls . length * 100 ) + '%;background:#1E4B9B"></div>'
+ '<div style="width:' + ( obs . length / cls . length * 100 ) + '%;background:#8A6A28"></div>'
+ '<div style="width:' + ( str . length / cls . length * 100 ) + '%;background:#B9B4A7"></div></div>' ) ;
h . push ( '<div class="legend">'
+ '<span><i style="background:#1E4B9B"></i>Curated \u2014 ' + cur . length + '</span>'
+ '<span><i style="background:#8A6A28"></i>Observed \u2014 ' + obs . length + '</span>'
+ '<span><i style="background:#B9B4A7"></i>Structural \u2014 ' + str . length + '</span></div>' ) ;
h . push ( '<div class="note" style="max-width:880px;margin-bottom:26px"><b>Only ' + seam . length
+ ' relations cross between the two.</b> ' + seam . map ( function ( r ) { return esc ( r . label ) ; } ) . join ( " and " )
+ ' \u2014 both proposed by the reconciliation job and confirmed by a steward. Everywhere else the '
+ 'graph is either pure governance or pure machine, which is what keeps the curation cost bounded.</div>' ) ;
h . push ( '<h2 class="sec">The layers</h2><div class="grid">' ) ;
M . layers . forEach ( function ( l ) {
var g = cls . filter ( function ( n ) { return n . layer === l . id ; } ) ;
if ( ! g . length ) return ;
var c = provColour ( g [ 0 ] . prov ) ;
h . push ( '<div class="card" data-layer="' + l . id + '"><div class="top" style="background:' + c + '"></div>'
+ '<div class="body"><div class="role" style="color:' + c + '">' + esc ( l . tag ) + '</div>'
+ '<h3>' + esc ( l . title ) + '</h3><p>' + g . slice ( 0 , 5 ) . map ( function ( n ) { return esc ( n . label ) ; } ) . join ( " \u00b7 " )
+ ( g . length > 5 ? " \u00b7 \u2026" : "" ) + '</p><div class="nums"><div><b>' + g . length + '</b>classes</div>'
+ '<div><b>' + rel . filter ( function ( r ) { return byId [ r . domain ] && byId [ r . domain ] . layer === l . id ; } ) . length
+ '</b>relations from</div></div></div></div>' ) ;
2026-07-26 21:28:14 +02:00
} ) ;
2026-07-29 09:03:59 +02:00
h . push ( '</div>' ) ;
return h . join ( "" ) ;
}
/* ------------------------------------------------------------ LAYERS */
function pill ( t , c ) { c = c || "#8A6A28" ;
return '<span class="pill" style="color:' + c + ';border-color:' + c + ';background:#FBF3E4">' + t + '</span>' ; }
function renderModel ( ) {
var h = [ '<p class="lede">Every class, in the layer it belongs to.</p>'
+ '<p class="sublede">Abstract classes are never instantiated \u2014 they exist to give their '
+ 'specialisations a common parent and a common shape. Superseded terms are kept rather than deleted '
+ 'so assertions written against them keep classifying; they live on the Deprecated tab.</p>' ] ;
M . layers . forEach ( function ( l ) {
var g = live ( M . nodes ) . filter ( function ( n ) { return n . layer === l . id ; } ) ;
if ( ! g . length ) return ;
g . sort ( function ( a , b ) { return a . depth - b . depth || a . label . localeCompare ( b . label ) ; } ) ;
h . push ( '<h2 class="sec" style="color:' + provColour ( g [ 0 ] . prov ) + '">' + esc ( l . title ) + ' \u00b7 ' + esc ( l . tag ) + '</h2>' ) ;
h . push ( '<table><thead><tr><th>Class</th><th>Provenance</th><th class="hide-s">Specialises</th>'
+ '<th class="hide-s">What it is for</th></tr></thead><tbody>' ) ;
g . forEach ( function ( n ) {
h . push ( '<tr data-id="' + n . id + '"><td><span class="dot" style="background:' + provColour ( n . prov )
+ '"></span><b>' + esc ( n . label ) + '</b>' + ( n . abstract ? ' ' + pill ( "abstract" ) : '' ) + '</td>'
+ '<td class="mono">' + n . prov + '</td><td class="hide-s mono">'
+ ( byId [ n . parent ] ? esc ( clean ( byId [ n . parent ] . label ) ) : "\u2014" ) + '</td>'
+ '<td class="hide-s">' + esc ( ( n . comment || "" ) . split ( ". " ) [ 0 ] ) + '</td></tr>' ) ;
} ) ;
h . push ( '</tbody></table>' ) ;
2026-07-26 21:28:14 +02:00
} ) ;
2026-07-29 09:03:59 +02:00
return h . join ( "" ) ;
2026-07-26 21:28:14 +02:00
}
2026-07-29 09:03:59 +02:00
/* ------------------------------------------------------------ RELATIONS */
function renderProps ( ) {
var h = [ '<p class="lede">' + live ( M . relations ) . length + ' relations and ' + live ( M . attributes ) . length + ' attributes.</p>'
+ '<p class="sublede">A property with no declared domain or range is deliberate: it serves several classes '
+ 'with different targets, so the scope lives in the SHACL shapes instead, stated per class.</p>' ] ;
h . push ( '<div class="filters"><input id="q" type="search" placeholder="filter\u2026">'
+ '<button data-k="" class="on">All</button><button data-k="Relation">Relations</button>'
+ '<button data-k="Attribute">Attributes</button><button data-k="Bridge">Bridges</button>'
+ '</div><div id="tbl"></div>' ) ;
return h . join ( "" ) ;
2026-07-26 21:28:14 +02:00
}
2026-07-29 09:03:59 +02:00
function propRows ( ) {
var all = M . relations . concat ( M . attributes , M . bridges ) . filter ( function ( p ) {
if ( p . deprecated ) return false ;
if ( filt . kind && p . kind !== filt . kind ) return false ;
if ( filt . q && ( p . label + " " + p . id + " " + ( p . comment || "" ) ) . toLowerCase ( ) . indexOf ( filt . q ) === - 1 ) return false ;
return true ;
} ) ;
all . sort ( function ( a , b ) { return a . kind . localeCompare ( b . kind ) || a . label . localeCompare ( b . label ) ; } ) ;
var h = [ '<table><thead><tr><th>Property</th><th class="hide-s">From</th><th class="hide-s">To</th>'
+ '<th>Traits</th></tr></thead><tbody>' ] ;
all . forEach ( function ( p ) {
var t = [ ] ;
if ( p . functional ) t . push ( "exactly one" ) ;
if ( p . transitive ) t . push ( "transitive" ) ;
if ( p . seam ) t . push ( "seam" ) ;
if ( p . curation ) t . push ( p . curation . toLowerCase ( ) . replace ( /_/g , " " ) ) ;
h . push ( '<tr data-id="' + p . id + '"><td><b>' + esc ( p . label ) + '</b><div class="mono">' + esc ( p . id ) + '</div></td>'
+ '<td class="hide-s">' + ( byId [ p . domain ] ? esc ( clean ( byId [ p . domain ] . label ) ) : '<span class="mono">any</span>' ) + '</td>'
+ '<td class="hide-s">' + ( byId [ p . range ] ? esc ( clean ( byId [ p . range ] . label ) )
: '<span class="mono">' + esc ( p . range || "\u2014" ) + '</span>' ) + '</td>'
+ '<td class="mono">' + ( t . join ( " \u00b7 " ) || "\u2014" ) + '</td></tr>' ) ;
} ) ;
h . push ( '</tbody></table><p class="sublede" style="margin-top:12px">' + all . length + ' shown.</p>' ) ;
return h . join ( "" ) ;
2026-07-26 21:28:14 +02:00
}
2026-07-29 09:03:59 +02:00
/* ------------------------------------------------------------ DEPRECATED */
function renderDep ( ) {
var dep = M . nodes . filter ( function ( n ) { return n . deprecated ; } )
. concat ( M . relations . concat ( M . attributes ) . filter ( function ( r ) { return r . deprecated ; } ) ) ;
var h = [ '<p class="lede">' + dep . length + ' terms kept, none of them to be used.</p>'
+ '<p class="sublede">Rule LC-002: a superseded term is deprecated rather than deleted, so assertions '
+ 'written against it keep classifying while a migration runs. The obligation is symmetric \u2014 a '
+ 'deprecation that never ends stops meaning anything, which is why Table was removed outright in v1.2 '
+ 'once nothing used it.</p>' ] ;
h . push ( '<table><thead><tr><th>Term</th><th>Kind</th><th>Replaced by</th>'
+ '<th class="hide-s">Why</th></tr></thead><tbody>' ) ;
dep . forEach ( function ( d ) {
h . push ( '<tr class="dep" data-id="' + d . id + '"><td><b>' + esc ( clean ( d . label ) ) + '</b>'
+ '<div class="mono">' + esc ( d . id ) + '</div></td><td class="mono">'
+ ( d . kind === "Class" ? "class" : d . kind . toLowerCase ( ) ) + '</td><td>'
+ ( byId [ d . replacedBy ] ? esc ( clean ( byId [ d . replacedBy ] . label ) ) : '<span class="mono">\u2014</span>' )
+ '</td><td class="hide-s">' + esc ( ( d . comment || "" ) . split ( ". " ) [ 0 ] ) + '</td></tr>' ) ;
} ) ;
h . push ( '</tbody></table>' ) ;
return h . join ( "" ) ;
2026-07-26 21:28:14 +02:00
}
2026-07-29 09:03:59 +02:00
/* ------------------------------------------------------------ SHELL */
function renderGraphShell ( ) {
var h = [ '<p class="lede">The meta model as a graph.</p>'
+ '<p class="sublede">Columns are provenance: <b style="color:#1E4B9B">curated</b> on the left, '
+ '<b style="color:#8A6A28">observed</b> on the right. Rows are layers. A <b>navy edge</b> crosses '
+ 'between the two \u2014 that is the seam, the only place a steward confirms anything. Dashed outlines '
+ 'are abstract classes, never instantiated.</p>' ] ;
h . push ( '<div class="gwrap"><svg id="g-svg" xmlns="http://www.w3.org/2000/svg"><defs>'
+ '<marker id="ga" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto">'
+ '<path d="M0,0 L10,5 L0,10 z" fill="#A6ABB5"/></marker>'
+ '<marker id="gg" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto">'
+ '<path d="M0,0 L10,5 L0,10 z" fill="#0A1F44"/></marker>'
+ '<marker id="gn" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto">'
+ '<path d="M0,0 L10,5 L0,10 z" fill="#B8935A"/></marker></defs>'
+ '<g id="g-vp"><g id="g-b"></g><g id="g-e"></g><g id="g-l"></g><g id="g-n"></g></g></svg>' ) ;
h . push ( '<div class="gctl"><div class="seg"><button id="g-strata" class="on">Strata</button>'
+ '<button id="g-network">Network</button></div><div class="gsec">Layers</div>'
+ M . layers . map ( function ( l ) {
var n = live ( M . nodes ) . filter ( function ( x ) { return x . layer === l . id ; } ) . length ;
if ( ! n ) return "" ;
return '<label><input type="checkbox" data-ly="' + l . id + '"' + ( G . layers [ l . id ] ? " checked" : "" )
+ '> ' + esc ( l . title ) + ' <span class="mono">' + n + '</span></label>' ; } ) . join ( "" )
+ '<div class="gsec">Provenance</div>'
+ LANES . map ( function ( L ) {
var n = live ( M . nodes ) . filter ( function ( x ) { return x . prov === L [ 0 ] ; } ) . length ;
if ( ! n ) return "" ;
return '<label><input type="checkbox" data-pv="' + L [ 0 ] + '"'
+ ( ! G . provs . length || G . provs . indexOf ( L [ 0 ] ) > - 1 ? " checked" : "" ) + '> <i style="background:' + L [ 2 ]
+ '"></i>' + L [ 0 ] + ' <span class="mono">' + n + '</span></label>' ; } ) . join ( "" )
+ '<div class="gsec">Options</div>'
+ '<label><input type="checkbox" id="g-lab" checked> Relation names</label>'
+ '<div class="mono" id="g-count" style="margin-top:8px"></div></div>' ) ;
h . push ( '<div class="gzoom"><button id="g-in">+</button><button id="g-out">\u2212</button>'
+ '<button id="g-fit" style="font-size:12px">\u21ba</button></div>' ) ;
h . push ( '<div class="ghint">drag to pan \u00b7 scroll to zoom \u00b7 click empty space to clear</div></div>' ) ;
return h . join ( "" ) ;
2026-07-26 21:28:14 +02:00
}
2026-07-29 09:03:59 +02:00
function wireGraph ( ) {
renderGraph ( ) ;
var svg = document . getElementById ( "g-svg" ) ;
function mode ( m ) {
G . mode = m ;
document . getElementById ( "g-strata" ) . classList . toggle ( "on" , m === "strata" ) ;
document . getElementById ( "g-network" ) . classList . toggle ( "on" , m === "network" ) ;
renderGraph ( ) ;
}
document . getElementById ( "g-strata" ) . onclick = function ( ) { mode ( "strata" ) ; } ;
document . getElementById ( "g-network" ) . onclick = function ( ) { mode ( "network" ) ; } ;
document . querySelectorAll ( "[data-ly]" ) . forEach ( function ( cb ) {
cb . onchange = function ( ) { G . layers [ cb . dataset . ly ] = cb . checked ; renderGraph ( ) ; } ;
} ) ;
document . getElementById ( "g-lab" ) . onchange = function ( ) {
G . labels = this . checked ; renderGraph ( ) ; } ;
document . querySelectorAll ( "[data-pv]" ) . forEach ( function ( cb ) {
cb . onchange = function ( ) {
var on = [ ] ;
document . querySelectorAll ( "[data-pv]" ) . forEach ( function ( x ) {
if ( x . checked ) on . push ( x . dataset . pv ) ; } ) ;
G . provs = ( on . length === LANES . length ) ? [ ] : on ;
renderGraph ( ) ;
} ;
} ) ;
document . getElementById ( "g-in" ) . onclick = function ( ) { zoomG ( 1.25 ) ; } ;
document . getElementById ( "g-out" ) . onclick = function ( ) { zoomG ( . 8 ) ; } ;
document . getElementById ( "g-fit" ) . onclick = fitGraph ;
var drag = false , moved = false , ds , vs , pinch = null ;
svg . addEventListener ( "mousedown" , function ( e ) {
drag = true ; moved = false ; svg . classList . add ( "drag" ) ;
ds = { x : e . clientX , y : e . clientY } ; vs = { x : G . view . x , y : G . view . y } ; } ) ;
window . addEventListener ( "mousemove" , function ( e ) {
if ( ! drag ) return ;
if ( Math . abs ( e . clientX - ds . x ) + Math . abs ( e . clientY - ds . y ) > 4 ) moved = true ;
G . view . x = vs . x + ( e . clientX - ds . x ) ; G . view . y = vs . y + ( e . clientY - ds . y ) ; applyView ( ) ; } ) ;
window . addEventListener ( "mouseup" , function ( ) {
drag = false ; svg . classList . remove ( "drag" ) ; } ) ;
/* click on empty canvas clears the selection -- a highlight that cannot be
dismissed traps the reader in one node */
svg . addEventListener ( "click" , function ( e ) {
if ( ! moved && ! e . target . closest ( ".gnode" ) ) { clearSel ( ) ; insp . classList . remove ( "open" ) ; }
} ) ;
svg . addEventListener ( "wheel" , function ( e ) { e . preventDefault ( ) ;
var f = e . deltaY < 0 ? 1.1 : . 9 , r = svg . getBoundingClientRect ( ) ;
var mx = e . clientX - r . left , my = e . clientY - r . top ;
var ns = Math . max ( . 1 , Math . min ( G . view . s * f , 3 ) ) ;
G . view . x = mx - ( mx - G . view . x ) * ( ns / G . view . s ) ; G . view . y = my - ( my - G . view . y ) * ( ns / G . view . s ) ;
G . view . s = ns ; applyView ( ) ; } , { passive : false } ) ;
svg . addEventListener ( "touchstart" , function ( e ) {
if ( e . touches . length === 1 ) { drag = true ; moved = false ;
ds = { x : e . touches [ 0 ] . clientX , y : e . touches [ 0 ] . clientY } ; vs = { x : G . view . x , y : G . view . y } ; }
else if ( e . touches . length === 2 ) { drag = false ;
var dx = e . touches [ 0 ] . clientX - e . touches [ 1 ] . clientX ,
dy = e . touches [ 0 ] . clientY - e . touches [ 1 ] . clientY ;
pinch = { d : Math . sqrt ( dx * dx + dy * dy ) , s : G . view . s } ; } } , { passive : true } ) ;
svg . addEventListener ( "touchmove" , function ( e ) {
if ( e . touches . length === 1 && drag ) { moved = true ;
G . view . x = vs . x + ( e . touches [ 0 ] . clientX - ds . x ) ;
G . view . y = vs . y + ( e . touches [ 0 ] . clientY - ds . y ) ; applyView ( ) ; e . preventDefault ( ) ; }
else if ( e . touches . length === 2 && pinch ) {
var dx = e . touches [ 0 ] . clientX - e . touches [ 1 ] . clientX ,
dy = e . touches [ 0 ] . clientY - e . touches [ 1 ] . clientY ;
var nd = Math . sqrt ( dx * dx + dy * dy ) , ns = Math . max ( . 1 , Math . min ( pinch . s * ( nd / pinch . d ) , 3 ) ) ;
var r = svg . getBoundingClientRect ( ) ;
G . view . x = r . width / 2 - ( r . width / 2 - G . view . x ) * ( ns / G . view . s ) ;
G . view . y = r . height / 2 - ( r . height / 2 - G . view . y ) * ( ns / G . view . s ) ;
G . view . s = ns ; applyView ( ) ; e . preventDefault ( ) ; } } , { passive : false } ) ;
svg . addEventListener ( "touchend" , function ( ) { drag = false ; pinch = null ; } ) ;
var nL = document . getElementById ( "g-n" ) ;
nL . addEventListener ( "click" , function ( e ) {
var g = e . target . closest ( ".gnode" ) ; if ( ! g ) return ;
e . stopPropagation ( ) ;
if ( G . sel === g . dataset . id ) { clearSel ( ) ; insp . classList . remove ( "open" ) ; return ; }
G . sel = g . dataset . id ; G . hot = null ; applyHot ( ) ; openCard ( g . dataset . id , true ) ;
} ) ;
nL . addEventListener ( "keydown" , function ( e ) {
var g = e . target . closest ( ".gnode" ) ;
if ( g && ( e . key === "Enter" || e . key === " " ) ) { e . preventDefault ( ) ;
G . sel = g . dataset . id ; applyHot ( ) ; openCard ( g . dataset . id , true ) ; } } ) ;
nL . addEventListener ( "mouseover" , function ( e ) {
var g = e . target . closest ( ".gnode" ) ; if ( g && ! G . sel ) { G . hot = g . dataset . id ; applyHot ( ) ; } } ) ;
nL . addEventListener ( "mouseout" , function ( e ) {
if ( ! G . sel && ( ! e . relatedTarget || ! e . relatedTarget . closest
|| ! e . relatedTarget . closest ( ".gnode" ) ) ) { G . hot = null ; applyHot ( ) ; } } ) ;
window . addEventListener ( "resize" , fitGraph ) ;
}
/* ------------------------------------------------------------ INSPECTOR */
var insp = document . getElementById ( "insp" ) , ibody = document . getElementById ( "insp-body" ) ;
var HIST = [ ] ;
var CHILDREN = { } , FROM = { } , TO = { } , ATTRS = { } ;
M . nodes . forEach ( function ( n ) {
if ( n . parent ) ( CHILDREN [ n . parent ] = CHILDREN [ n . parent ] || [ ] ) . push ( n . id ) ; } ) ;
M . relations . forEach ( function ( r ) {
if ( r . deprecated ) return ;
if ( r . domain ) ( FROM [ r . domain ] = FROM [ r . domain ] || [ ] ) . push ( r . id ) ;
if ( r . range ) ( TO [ r . range ] = TO [ r . range ] || [ ] ) . push ( r . id ) ; } ) ;
M . attributes . forEach ( function ( a ) {
if ( a . deprecated || ! a . domain ) return ;
( ATTRS [ a . domain ] = ATTRS [ a . domain ] || [ ] ) . push ( a . id ) ; } ) ;
function openCard ( id , reset ) {
if ( reset ) HIST = [ ] ;
var cur = HIST [ HIST . length - 1 ] ;
if ( cur && cur !== id ) HIST . push ( id ) ; else if ( ! cur ) HIST . push ( id ) ;
paint ( id ) ;
}
function back ( ) {
HIST . pop ( ) ;
var prev = HIST [ HIST . length - 1 ] ;
if ( prev ) { paint ( prev ) ; if ( G . sel ) { G . sel = prev ; applyHot ( ) ; } }
else insp . classList . remove ( "open" ) ;
2026-07-26 21:28:14 +02:00
}
2026-07-29 09:03:59 +02:00
function open ( id ) { openCard ( id , true ) ; }
function paint ( id ) {
var n = byId [ id ] ; if ( ! n ) return ;
var h = [ ] ;
if ( HIST . length > 1 )
h . push ( '<button class="backlink" id="i-back">\u2190 back to '
+ esc ( clean ( byId [ HIST [ HIST . length - 2 ] ] . label ) ) + '</button>' ) ;
var kindLabel = n . kind === "Class" ? "Class" : n . kind === "Relation" ? "Relation"
: n . kind === "Attribute" ? "Attribute" : "Bridge property" ;
h . push ( '<div class="kind" style="color:' + provColour ( n . prov || "structural" ) + '">'
+ kindLabel + ( n . layer ? ' \u00b7 ' + esc ( layerTitle ( n . layer ) ) : '' ) + '</div>' ) ;
h . push ( '<h3>' + esc ( clean ( n . label ) ) + '</h3>' ) ;
h . push ( '<div class="iri">' + esc ( n . id ) + '</div>' ) ;
if ( n . deprecated ) h . push ( '<div class="note"><b>Deprecated.</b> Kept so assertions written '
+ 'against it keep classifying (LC-002)'
+ ( byId [ n . replacedBy ] ? ', replaced by <b>' + esc ( clean ( byId [ n . replacedBy ] . label ) ) + '</b>' : '' )
+ '. Not to be used in new work.</div>' ) ;
/* 1. attributes, always first and always in the same place */
var rows = [ ] ;
function at ( l , v ) { if ( v ) rows . push ( [ l , v ] ) ; }
if ( n . kind === "Class" ) {
at ( "Provenance" , n . prov ) ;
at ( "Instantiated" , n . abstract ? "never \u2014 abstract" : "yes" ) ;
at ( "Curation mode" , ( n . curation || "" ) . toLowerCase ( ) . replace ( /_/g , " " ) ) ;
at ( "Acronym" , n . acronym ) ;
} else {
at ( "Cardinality" , n . functional ? "exactly one" : "many" ) ;
at ( "Transitive" , n . transitive ? "yes" : "" ) ;
at ( "Crosses the seam" , n . seam ? "yes" : "" ) ;
at ( "Curation mode" , ( n . curation || "" ) . toLowerCase ( ) . replace ( /_/g , " " ) ) ;
if ( ! byId [ n . range ] ) at ( "Range" , n . range ) ;
if ( ! n . domain ) at ( "Domain" , "any \u2014 scoped by SHACL per class" ) ;
}
if ( rows . length ) h . push ( '<h4>Attributes</h4>' + rows . map ( function ( r ) {
return '<div class="row"><span>' + r [ 0 ] + '</span><span class="mono">' + esc ( r [ 1 ] ) + '</span></div>' ;
} ) . join ( "" ) ) ;
/* 2. what it is for */
if ( n . comment ) h . push ( '<h4>What it is for</h4><p>' + esc ( n . comment ) . replace ( /\n/g , "<br>" ) + '</p>' ) ;
if ( n . harvest ) h . push ( '<h4>Harvested from</h4><div class="harv">' + esc ( n . harvest ) + '</div>' ) ;
/* 3. related terms, from the coarsest down */
function link ( label , ids ) {
ids = ( ids || [ ] ) . filter ( function ( i ) { return byId [ i ] ; } ) ;
if ( ! ids . length ) return ;
h . push ( '<h4>' + label + '</h4>' ) ;
ids . slice ( 0 , 16 ) . forEach ( function ( i ) {
h . push ( '<div class="row"><button class="jump" data-j="' + i + '">' + esc ( clean ( byId [ i ] . label ) )
+ '</button><span class="mono">' + ( byId [ i ] . deprecated ? "deprecated"
: byId [ i ] . kind === "Class" ? ( byId [ i ] . prov || "" ) : byId [ i ] . kind . toLowerCase ( ) ) + '</span></div>' ) ;
} ) ;
if ( ids . length > 16 ) h . push ( '<div class="row"><span class="mono">\u2026 and '
+ ( ids . length - 16 ) + ' more</span><span></span></div>' ) ;
}
link ( "Specialises" , [ n . parent , n . subPropertyOf ] ) ;
link ( "Specialised by" , CHILDREN [ n . id ] ) ;
link ( "Replaced by" , [ n . replacedBy ] ) ;
link ( "From" , [ n . domain ] ) ;
link ( "To" , [ n . range ] ) ;
link ( "Inverse of" , [ n . inverseOf ] ) ;
link ( "Relations from here" , ( FROM [ n . id ] || [ ] ) ) ;
link ( "Relations to here" , ( TO [ n . id ] || [ ] ) ) ;
link ( "Attributes carried" , ATTRS [ n . id ] ) ;
if ( n . members && n . members . length )
h . push ( '<h4>Allowed values</h4>' + n . members . map ( function ( m ) {
return '<div class="row"><span>' + esc ( m . label ) + '</span><span class="mono">'
+ esc ( m . id ) + '</span></div>' ; } ) . join ( "" ) ) ;
ibody . innerHTML = h . join ( "" ) ;
insp . classList . add ( "open" ) ;
var bk = document . getElementById ( "i-back" ) ; if ( bk ) bk . onclick = back ;
ibody . querySelectorAll ( ".jump" ) . forEach ( function ( b ) {
b . onclick = function ( ) { HIST . push ( b . dataset . j ) ; paint ( b . dataset . j ) ;
if ( G . sel ) { G . sel = b . dataset . j ; applyHot ( ) ; } } ; } ) ;
2026-07-26 21:28:14 +02:00
}
2026-07-29 09:03:59 +02:00
document . getElementById ( "insp-x" ) . onclick = function ( ) {
insp . classList . remove ( "open" ) ; clearSel ( ) ; } ;
document . addEventListener ( "keydown" , function ( e ) {
if ( e . key === "Escape" ) { insp . classList . remove ( "open" ) ; clearSel ( ) ; } } ) ;
/* ------------------------------------------------------------ WIRING */
var host = document . getElementById ( "view" ) ;
function render ( ) {
host . innerHTML = view === "scope" ? renderScope ( )
: view === "model" ? renderModel ( )
: view === "graph" ? renderGraphShell ( )
: view === "props" ? renderProps ( ) : renderDep ( ) ;
if ( view === "graph" ) wireGraph ( ) ;
if ( view === "props" ) {
document . getElementById ( "tbl" ) . innerHTML = propRows ( ) ;
document . getElementById ( "q" ) . oninput = function ( ) {
filt . q = this . value . trim ( ) . toLowerCase ( ) ;
document . getElementById ( "tbl" ) . innerHTML = propRows ( ) ; bindRows ( ) ; } ;
host . querySelectorAll ( "[data-k]" ) . forEach ( function ( b ) {
b . onclick = function ( ) {
filt . kind = b . dataset . k ;
host . querySelectorAll ( "[data-k]" ) . forEach ( function ( x ) {
x . classList . toggle ( "on" , x === b ) ; } ) ;
document . getElementById ( "tbl" ) . innerHTML = propRows ( ) ; bindRows ( ) ; } ; } ) ;
}
bindRows ( ) ;
host . querySelectorAll ( ".card" ) . forEach ( function ( c ) {
c . onclick = function ( ) {
M . layers . forEach ( function ( l ) { G . layers [ l . id ] = ( l . id === c . dataset . layer ) ; } ) ;
setView ( "graph" ) ; } ; } ) ;
2026-07-26 21:28:14 +02:00
}
2026-07-29 09:03:59 +02:00
function bindRows ( ) {
host . querySelectorAll ( "tr[data-id]" ) . forEach ( function ( tr ) {
tr . onclick = function ( ) { open ( tr . dataset . id ) ; } ; } ) ;
2026-07-26 21:28:14 +02:00
}
2026-07-29 09:03:59 +02:00
function setView ( v ) {
view = v ;
[ "scope" , "model" , "graph" , "props" , "dep" ] . forEach ( function ( k ) {
document . getElementById ( "t-" + k ) . classList . toggle ( "on" , k === v ) ; } ) ;
render ( ) ;
document . querySelector ( "main" ) . scrollTop = 0 ;
}
[ "scope" , "model" , "graph" , "props" , "dep" ] . forEach ( function ( k ) {
document . getElementById ( "t-" + k ) . onclick = function ( ) {
if ( k === "graph" ) M . layers . forEach ( function ( l ) { G . layers [ l . id ] = ( l . id !== "vocab" ) ; } ) ;
setView ( k ) ; } ; } ) ;
render ( ) ;
2026-07-26 21:28:14 +02:00
< / script >
< / body >
< / html >