Files
data-meta-model/generated/pr_metamodel_viewer.html
T

1028 lines
120 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PR Data Meta Model &middot; T-Box</title>
<style>
:root{
--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;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
--serif:Georgia,"Times New Roman",serif;
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;height:100%}
body{font-family:var(--sans);background:var(--bg);color:var(--ink);
display:flex;flex-direction:column;overflow:hidden}
:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
header{background:var(--navy);color:#fff;padding:11px 20px;display:flex;
align-items:center;gap:18px;flex-shrink:0}
header h1{margin:0;font-family:var(--mono);font-size:12.5px;font-weight:600;
letter-spacing:.17em;text-transform:uppercase}
header .sub{font-size:11px;color:rgba(255,255,255,.6);margin-top:3px}
.tabs{margin-left:auto;display:flex;gap:2px;background:rgba(255,255,255,.08);
padding:3px;border-radius:4px}
.tabs button{padding:6px 14px;border:none;background:transparent;color:rgba(255,255,255,.7);
font-family:var(--mono);font-size:10px;letter-spacing:.09em;text-transform:uppercase;
cursor:pointer;border-radius:3px;font-weight:600}
.tabs button.on{background:#fff;color:var(--navy)}
main{flex:1;overflow-y:auto;padding:22px 20px 60px}
.wrap{max-width:1220px;margin:0 auto}
.lede{font-family:var(--serif);font-size:21px;line-height:1.45;color:var(--navy);
margin:0 0 6px;max-width:860px}
.lede b{color:var(--gold)}
.sublede{font-size:13px;color:var(--muted);margin:0 0 22px;max-width:860px;line-height:1.55}
.kpis{display:flex;gap:26px;flex-wrap:wrap;padding:16px 0 20px;
border-top:1px solid var(--rule);border-bottom:1px solid var(--rule);margin-bottom:24px}
.kpi b{display:block;font-family:var(--serif);font-size:29px;color:var(--navy);line-height:1}
.kpi.warn b{color:var(--gold)}
.kpi span{font-size:9.5px;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);
display:block;margin-top:5px}
h2.sec{font-family:var(--mono);font-size:10px;letter-spacing:.15em;text-transform:uppercase;
color:var(--muted);margin:30px 0 12px;font-weight:600}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}
.card{background:var(--panel);border:1px solid var(--rule);border-radius:4px;
padding:0;overflow:hidden;cursor:pointer;transition:box-shadow .15s}
.card:hover{box-shadow:0 3px 14px rgba(10,31,68,.10)}
.card .top{height:4px}
.card .body{padding:15px 17px 16px}
.card .role{font-family:var(--mono);font-size:8.5px;letter-spacing:.13em;
text-transform:uppercase;font-weight:600}
.card h3{margin:5px 0 2px;font-family:var(--serif);font-size:19px;color:var(--navy)}
.card .who{font-size:11.5px;color:var(--muted);margin-bottom:12px}
.card .nums{display:flex;gap:15px;flex-wrap:wrap;padding-top:11px;border-top:1px solid var(--rule)}
.card .nums div{font-size:10.5px;color:var(--muted)}
.card .nums b{display:block;font-family:var(--mono);font-size:15px;color:var(--ink)}
.flag{display:inline-block;margin-top:11px;padding:4px 9px;border-radius:3px;
background:#FBF3E4;border:1px solid var(--gold);color:#7A5B2A;
font-family:var(--mono);font-size:9.5px;letter-spacing:.05em}
table{width:100%;border-collapse:collapse;background:var(--panel);font-size:12.5px}
th{text-align:left;padding:9px 11px;font-family:var(--mono);font-size:9px;
letter-spacing:.1em;text-transform:uppercase;color:var(--muted);
border-bottom:1.5px solid var(--navy);white-space:nowrap;background:var(--panel);
position:sticky;top:0;cursor:pointer}
td{padding:8px 11px;border-bottom:1px 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:8px;height:8px;border-radius:2px;margin-right:7px;
vertical-align:baseline}
.mono{font-family:var(--mono);font-size:10.5px;color:var(--muted)}
.pill{display:inline-block;padding:2px 7px;border-radius:2px;font-family:var(--mono);
font-size:8.5px;letter-spacing:.06em;text-transform:uppercase;border:1px solid}
.bar{display:flex;height:26px;border-radius:3px;overflow:hidden;margin:6px 0 4px}
.bar div{position:relative}
.legend{display:flex;gap:16px;flex-wrap:wrap;font-size:11px;color:var(--muted);margin-bottom:20px}
.legend i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:6px}
.filters{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center}
.filters input{padding:7px 11px;border:1px solid var(--rule);border-radius:3px;
font-family:var(--mono);font-size:12px;min-width:180px;background:#fff}
.filters button{padding:6px 12px;border:1px solid var(--rule);background:#fff;
border-radius:3px;font-family:var(--mono);font-size:10px;cursor:pointer;
text-transform:uppercase;letter-spacing:.07em;color:var(--muted)}
.filters button.on{background:var(--navy);color:#fff;border-color:var(--navy)}
#insp{position:fixed;top:0;right:0;width:390px;max-width:100%;height:100%;
background:var(--panel);border-left:1px solid var(--rule);
box-shadow:-8px 0 30px rgba(10,31,68,.13);overflow-y:auto;padding:22px 22px 60px;
transform:translateX(100%);transition:transform .22s;z-index:50}
#insp.open{transform:translateX(0)}
#insp .x{position:absolute;top:13px;right:15px;border:none;background:none;
font-size:23px;cursor:pointer;color:var(--muted);line-height:1}
.inh{font-style:italic;color:#8A6A28;font-size:9px;text-transform:uppercase;
letter-spacing:.06em;font-family:var(--mono)}
.backlink{background:none;border:none;padding:0 0 10px;font-family:var(--mono);
font-size:10px;color:var(--navy);cursor:pointer;letter-spacing:.05em}
.kind{font-family:var(--mono);font-size:9px;letter-spacing:.14em;
text-transform:uppercase;font-weight:600}
.gctl .gsec{font-family:var(--mono);font-size:8.5px;letter-spacing:.12em;
text-transform:uppercase;color:var(--muted);margin:9px 0 4px;font-weight:600}
.gctl select{width:100%;padding:4px;font-family:var(--mono);font-size:10px;
border:1px solid var(--rule);border-radius:3px;margin-top:5px}
.lanelabel{font-family:var(--mono);font-size:10px;letter-spacing:.1em;font-weight:600}
.elabel{font-family:var(--mono);font-size:7.5px;fill:#5A6270;text-anchor:middle;
pointer-events:none}
.elbg{fill:#F4F2EC;pointer-events:none}
.elabel.dim,.elbg.dim{opacity:.06}
#insp h3{margin:7px 0 2px;font-family:var(--serif);font-size:21px;color:var(--navy)}
#insp .iri{font-family:var(--mono);font-size:10.5px;color:var(--muted);margin-bottom:15px}
#insp h4{font-family:var(--mono);font-size:9px;letter-spacing:.14em;text-transform:uppercase;
color:var(--muted);margin:19px 0 7px;border-top:1px solid var(--rule);padding-top:11px}
#insp p{font-size:12.5px;line-height:1.6;margin:0 0 8px}
.row{display:flex;justify-content:space-between;gap:12px;padding:5px 0;
border-bottom:1px dotted var(--rule);font-size:12px}
.row span:last-child{color:var(--muted);text-align:right}
.jump{background:none;border:none;padding:0;font:inherit;color:var(--navy);
cursor:pointer;text-decoration:underline;text-underline-offset:2px;font-size:12px}
.note{background:#FBF3E4;border-left:3px solid var(--gold);padding:10px 12px;
font-size:11.5px;line-height:1.55;color:#6B5426}
/* ---- graph ---- */
.gwrap{position:relative;height:calc(100vh - 250px);min-height:420px;
border:1px solid var(--rule);border-radius:4px;overflow:hidden;background:
linear-gradient(90deg,rgba(10,31,68,.03) 1px,transparent 1px) 0 0/26px 26px,
linear-gradient(180deg,#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.5px;letter-spacing:.14em;
text-transform:uppercase;font-weight:600}
.bandtag{font-size:9px;fill:var(--muted);font-style:italic}
.gnode{cursor:pointer}
.gnode rect{stroke-width:1.4}
.gnode text{font-size:9px;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:12px;top:12px;background:rgba(255,255,255,.94);
border:1px solid var(--rule);border-radius:4px;padding:9px 11px;font-size:11px;
max-width:230px}
.gctl .seg{display:flex;border:1px solid var(--rule);border-radius:3px;overflow:hidden;
margin-bottom:8px}
.gctl .seg button{flex:1;padding:5px;border:none;background:#fff;cursor:pointer;
font-family:var(--mono);font-size:9px;text-transform:uppercase;letter-spacing:.06em;
color:var(--muted)}
.gctl .seg button+button{border-left:1px solid var(--rule)}
.gctl .seg button.on{background:var(--navy);color:#fff}
.gctl label{display:flex;align-items:center;gap:7px;padding:3px 0;cursor:pointer}
.gctl label i{display:inline-block;width:8px;height:8px;border-radius:2px;flex-shrink:0}
.gctl input{accent-color:var(--navy)}
.gzoom{position:absolute;right:12px;bottom:12px;display:flex;gap:3px;
background:#fff;border:1px solid var(--rule);border-radius:4px;padding:3px}
.gzoom button{width:30px;height:30px;border:none;background:#fff;cursor:pointer;
font-size:15px;color:var(--navy);border-radius:3px}
.ghint{position:absolute;left:12px;bottom:12px;font-family:var(--mono);font-size:9px;
color:var(--muted);background:rgba(255,255,255,.85);padding:4px 8px;border-radius:3px}
footer{background:var(--navy);color:rgba(255,255,255,.5);padding:7px 20px;
font-family:var(--mono);font-size:9px;letter-spacing:.06em;flex-shrink:0;
display:flex;justify-content:space-between}
@media(max-width:760px){
.tabs{width:100%;margin:8px 0 0}header{flex-wrap:wrap}
.kpis{gap:18px}.kpi b{font-size:23px}
table{font-size:11.5px}td,th{padding:7px 8px}
.hide-s{display:none}
}
@media(prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
</head>
<body>
<header>
<div>
<h1>PR Data Meta Model &middot; T-Box</h1>
<div class="sub">Six layers, crossed with what governance defines and what a harvester captures</div>
</div>
<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">Properties</button>
<button id="t-dep">Deprecated</button>
</div>
</header>
<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">&times;</button>
<div id="insp-body"></div>
</div>
<footer>
<span>Build artefact of pr_metamodel.ttl &mdash; regenerate, never edit</span>
<span id="f-count"></span>
</footer>
<script>
var M = {"nodes":[{"id":"pr:MetaModelObject","kind":"Class","label":"Meta Model Object","shortLabel":"","layer":"roots","prov":"structural","depth":0,"parent":"","comment":"Anything the meta model describes. Actors are deliberately NOT Meta Model Objects.","harvest":"","authoring":"","acronym":"","deprecated":false,"replacedBy":"","abstract":true,"members":[]},{"id":"pr:DefinedObject","kind":"Class","label":"Defined Object","shortLabel":"","layer":"roots","prov":"defined","depth":1,"parent":"pr:MetaModelObject","comment":"Somebody decided it. Written by governance, edited through a pull request on the domain TTL file, and subject to the full identity and lifecycle shape: identifier, name, owner, owning Data Domain, status, version.","harvest":"","authoring":"ASSERTED","acronym":"","deprecated":false,"replacedBy":"","abstract":true,"members":[]},{"id":"pr:CuratedObject","kind":"Class","label":"Curated Object (deprecated)","shortLabel":"","layer":"roots","prov":"structural","depth":0,"parent":"","comment":"v1.4 term. Renamed because 'curated' is governance jargon; 'defined' says the same thing to a business reader. No instance ever named this class directly.","harvest":"","authoring":"","acronym":"","deprecated":true,"replacedBy":"pr:DefinedObject","abstract":false,"members":[]},{"id":"pr:CapturedObject","kind":"Class","label":"Captured Object","shortLabel":"","layer":"roots","prov":"captured","depth":1,"parent":"pr:MetaModelObject","comment":"Read from a running system and rebuilt on every run. Never hand-edited. Carries sourceIdentifier, fullyQualifiedName and harvestedOn instead of declared ownership: attribution is INFERRED through the Data Element it is mapped to, never asserted on the node.","harvest":"","authoring":"HARVESTED","acronym":"","deprecated":false,"replacedBy":"","abstract":true,"members":[]},{"id":"pr:ObservedObject","kind":"Class","label":"Observed Object (deprecated)","shortLabel":"","layer":"roots","prov":"structural","depth":0,"parent":"","comment":"v1.4 term. Renamed because 'observed' is passive and technical; 'captured' says where it came from. No instance ever named this class directly.","harvest":"","authoring":"","acronym":"","deprecated":true,"replacedBy":"pr:CapturedObject","abstract":false,"members":[]},{"id":"pr:OwnershipLayerObject","kind":"Class","label":"Ownership & Categorization Layer Object","shortLabel":"","layer":"ownership","prov":"structural","depth":1,"parent":"pr:MetaModelObject","comment":"Who is accountable, and how the estate is categorised: domains, sub-domains, and the business objects they answer for. A layer root, so it says nothing about provenance.","harvest":"","authoring":"","acronym":"","deprecated":false,"replacedBy":"","abstract":true,"members":[]},{"id":"pr:BusinessLayerObject","kind":"Class","label":"Business Layer Object","shortLabel":"","layer":"business","prov":"structural","depth":1,"parent":"pr:MetaModelObject","comment":"What the business means: the shared vocabulary and the measures built on it. A layer root, so it says nothing about provenance.","harvest":"","authoring":"","acronym":"","deprecated":false,"replacedBy":"","abstract":true,"members":[]},{"id":"pr:LogicalLayerObject","kind":"Class","label":"Logical Layer Object","shortLabel":"","layer":"logical","prov":"structural","depth":1,"parent":"pr:MetaModelObject","comment":"How the data is structured.","harvest":"","authoring":"","acronym":"","deprecated":false,"replacedBy":"","abstract":true,"members":[]},{"id":"pr:DeliveryLayerObject","kind":"Class","label":"Delivery Layer Object","shortLabel":"","layer":"delivery","prov":"structural","depth":1,"parent":"pr:MetaModelObject","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":"","authoring":"","acronym":"","deprecated":false,"replacedBy":"","abstract":true,"members":[]},{"id":"pr:PhysicalLayerObject","kind":"Class","label":"Physical Layer Object","shortLabel":"","layer":"physical","prov":"structural","
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 === "captured" ? "#8A6A28" : p === "defined" ? "#1E4B9B" : "#6B6B6B";
}
/* Scoped relations: these carry no rdfs:domain or rdfs:range on purpose,
because each serves several classes with different targets and the scope
belongs in the SHACL shapes. Both the graph and the cards read this list,
so an edge drawn in one is never missing from the other. */
var SCOPED_PAIRS = [
["pr:belongsTo", "pr:SubDomain", "pr:DataDomain", "belongs to"],
["pr:belongsTo", "pr:BusinessObject", "pr:SubDomain", "belongs to"],
["pr:represents", "pr:DataObject", "pr:BusinessObject", "represents"],
["pr:represents", "pr:DataElement", "pr:BusinessConcept", "represents"],
["pr:monitoredBy", "pr:BusinessObject", "pr:DataSteward", "monitored by"],
["pr:owningDomain", "pr:DefinedObject", "pr:DataDomain", "owning domain"],
["pr:ownedBy", "pr:DefinedObject", "pr:Actor", "owned by"]
];
var ORDER = [
"pr:MetaModelObject", "pr:DefinedObject", "pr:CapturedObject",
"pr:OwnershipLayerObject", "pr:DataDomain", "pr:SubDomain", "pr:BusinessObject",
"pr:BusinessLayerObject", "pr:BusinessConcept", "pr:Metric", "pr:KPI",
"pr:LogicalLayerObject", "pr:DataObject", "pr:DataElement",
"pr:DeliveryLayerObject", "pr:DataProduct", "pr:DataContract", "pr:DataInterface",
"pr:PhysicalLayerObject", "pr:System", "pr:Database", "pr:Schema",
"pr:DataStructure", "pr:BaseTable", "pr:View", "pr:ExternalTable", "pr:Field",
"pr:KeyConstraint", "pr:PrimaryKey", "pr:ForeignKey", "pr:Transformation",
"pr:ConsumptionLayerObject", "pr:BIWorkspace", "pr:BIDataSource", "pr:BIField",
"pr:BIReport",
"pr:Actor", "pr:DataDomainOwner", "pr:SubDomainOwner", "pr:DataGovernanceLead",
"pr:DataSteward", "pr:DataProductOwner"
];
var LANES = [["defined","Defined","#1E4B9B"],
["captured","Captured","#8A6A28"],
["structural","Structural","#6B6B6B"]];
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;
/* row = rank inside the layer, so the hierarchy reads DOWNWARD:
layer root, then Domain, then Sub-domain, then Business object.
Depth alone put siblings side by side, which read left to right. */
var rank = ORDER.indexOf(n.id);
keep[n.id] = {n:n, x:0, y:0,
layer: M.layers.map(function(l){return l.id;}).indexOf(n.layer),
row: rank > -1 ? rank : 90 + Math.min(n.depth, 5)};
});
var E = [];
/* Properties deliberately left without rdfs:domain/range serve several
classes with different targets, and their scope lives in the SHACL
shapes instead. The graph reads domain and range, so those edges were
simply absent -- Business Object to Sub-domain among them. The pairs are
declared here, mirroring the shapes, rather than added to the T-Box where
they would over-constrain the property. */
SCOPED_PAIRS.forEach(function(sc){
if(byId[sc[0]] && !byId[sc[0]].deprecated && keep[sc[1]] && keep[sc[2]]
&& sc[1] !== sc[2])
E.push({s:sc[1], t:sc[2], l:sc[3], cross:false});
});
var _unused = [
["pr:belongsTo", "pr:SubDomain", "pr:DataDomain", "belongs to"],
["pr:belongsTo", "pr:BusinessObject", "pr:SubDomain", "belongs to"],
["pr:represents", "pr:DataObject", "pr:BusinessObject", "represents"],
["pr:represents", "pr:DataElement", "pr:BusinessConcept", "represents"],
["pr:monitoredBy", "pr:BusinessObject", "pr:DataSteward", "monitored by"],
["pr:owningDomain", "pr:DefinedObject", "pr:DataDomain", "owning domain"],
["pr:ownedBy", "pr:DefinedObject", "pr:Actor", "owned by"]
];
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});
});
live(M.nodes).forEach(function(n){
if(n.parent && keep[n.id] && keep[n.parent])
E.push({s:n.id, t:n.parent, l:"type of", cross:false});
});
G.nodes = Object.keys(keep).map(function(k){ return keep[k]; });
G.edges = E;
return keep;
}
/* 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; }); });
/* One class per row. A lane is one column wide, so a layer reads DOWNWARDS:
the layer class on top, then Domain, then Sub-domain, then Business
Object. That is how a hierarchy is read; laying them left to right made
the reader scan across for something that descends. */
var laneW = {}, x = GUT;
G._lanes = [];
lanes.forEach(function(code){
var w = NW + LANEPAD*2;
laneW[code] = {x:x, w:w};
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;
});
G._width = x;
var y = 0;
M.layers.forEach(function(b, li){
b._y = y;
var any = G.nodes.filter(function(n){ return n.layer === li; });
if(!any.length){ b._h = 0; return; }
var cursor = y + 36, rows = 0;
lanes.forEach(function(code){
var g = any.filter(function(n){ return n.n.prov === code; });
if(!g.length) return;
g.sort(function(p, q){
return ((ORDER.indexOf(p.n.id)+1)||99) - ((ORDER.indexOf(q.n.id)+1)||99)
|| p.n.label.localeCompare(q.n.label); });
g.forEach(function(n, i){
n.x = laneW[code].x + laneW[code].w/2;
n.y = cursor + i*(NH+ROWGAP) + NH/2;
});
rows = Math.max(rows, g.length);
});
b._h = rows*(NH+ROWGAP) + 48;
y += b._h + BANDGAP;
});
G._height = y;
}
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;
}
a.x += (600-a.x)*.003*k; a.y += (400-a.y)*.004*k;
}
links.forEach(function(e){
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;
});
}
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;
}
}
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 === "defined";
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});
/* clipped to the column: a label wider than its lane overlapped the
neighbouring one, which read as though it belonged to both */
var cap = Math.floor(l.w / 6.4);
t.textContent = l.label.length > cap ? l.label.slice(0, cap-1) + "\u2026"
: 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"});
g.textContent=b.tag; bL.appendChild(g);
});
}
/* 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);
}
});
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.shortLabel || n.n.label, 20);
lines.forEach(function(t,i){
var e = el("text",{x:NW/2, y:NH/2+3+(i-(lines.length-1)/2)*9.5, fill:"#1A1A1A"});
e.textContent = t; g.appendChild(e);
});
nL.appendChild(g);
});
applyHot();
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";
}
/* A node box is NW wide and holds at most three lines. A word longer than a
line is cut rather than allowed to run past the border, and anything beyond
the third line is elided -- overflowing text overlapped the neighbouring
column, which is worse than an ellipsis. */
function wrapText(s, max){
var out = [], cur = "";
s.split(" ").forEach(function(w){
while(w.length > max){
if(cur){ out.push(cur); cur = ""; }
out.push(w.slice(0, max - 1) + "\u2011");
w = w.slice(max - 1);
}
if((cur + " " + w).trim().length > max){ if(cur) out.push(cur); cur = w; }
else cur = (cur + " " + w).trim();
});
if(cur) out.push(cur);
if(out.length > 3){
out = out.slice(0, 3);
out[2] = out[2].slice(0, Math.max(0, max - 1)) + "\u2026";
}
return out;
}
function applyHot(){
var id = G.sel || G.hot;
var ns=document.querySelectorAll(".gnode"), es=document.querySelectorAll(".gedge"),
ls=document.querySelectorAll(".elabel,.elbg");
if(!id){
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"); });
return;
}
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==="defined";});
var obs=cls.filter(function(n){return n.prov==="captured";});
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">Defined objects are written by governance and reviewed through a pull request. '
+ 'Captured objects are read 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">Defined against captured</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>Defined \u2014 '+cur.length+'</span>'
+'<span><i style="background:#8A6A28"></i>Captured \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 mapping effort 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>');
});
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;
/* ORDER alone, never depth first: sorting by depth grouped all the
parents together and pushed Base table, View and External table below
Field and Key constraint, away from the Data Structure they specialise. */
g.sort(function(a,b){
return ((ORDER.indexOf(a.id)+1)||99) - ((ORDER.indexOf(b.id)+1)||99)
|| 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">Type of</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 style="padding-left:'+(11+n.depth*16)+'px">'
+'<span class="dot" style="background:'+provColour(n.prov)
+'"></span><b>'+esc(n.shortLabel||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>');
});
return h.join("");
}
/* ------------------------------------------------------------ RELATIONS */
function renderProps(){
var h=['<p class="lede">'+live(M.relations).length+' relations, '+live(M.attributes).length+' attributes and '+M.bridges.length+' bridge properties.</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("");
}
/* First sentence only: the table is for scanning, the card carries the full
text. Splitting on ". " alone broke on abbreviations and on the multi-line
comments, which start with a newline. */
function firstSentence(t){
t = (t||"").replace(/\s+/g," ").trim();
var m = t.match(/^(.{0,150}?[.!?])(\s|$)/);
return m ? m[1] : (t.length > 150 ? t.slice(0,150).replace(/\s\S*$/,"") + "\u2026" : t);
}
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 class="hide-s">Traits</th>'
+'<th>What it is for</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.authoring) t.push(p.authoring.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="hide-s mono">'+(t.join(" \u00b7 ")||"\u2014")+'</td>'
+'<td>'+(p.comment ? esc(firstSentence(p.comment))
: '<span class="mono">no description</span>')+'</td></tr>');
});
h.push('</tbody></table><p class="sublede" style="margin-top:12px">'+all.length+' shown.</p>');
return h.join("");
}
/* ------------------------------------------------------------ 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("");
}
/* ------------------------------------------------------------ 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">defined</b> on the left, '
+'<b style="color:#8A6A28">captured</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("");
}
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); });
/* The same scoped pairs the graph uses. Without them a Business Object shows
no incoming relation at all, because belongsTo and represents carry no
rdfs:domain -- their scope lives in the shapes. The card was silent about
edges the graph was drawing. */
SCOPED_PAIRS.forEach(function(sc){
if(!byId[sc[0]] || byId[sc[0]].deprecated) return;
(FROM[sc[1]] = FROM[sc[1]]||[]).push(sc[0]);
(TO[sc[2]] = TO[sc[2]]||[]).push(sc[0]);
});
/* the scoped properties again: without this a Sub-domain card showed no
incoming Business Object, and a Business Object card had no incoming list
at all, because neither relation declares a domain or a range */
[["pr:belongsTo","pr:SubDomain","pr:DataDomain"],
["pr:belongsTo","pr:BusinessObject","pr:SubDomain"],
["pr:represents","pr:DataObject","pr:BusinessObject"],
["pr:represents","pr:DataElement","pr:BusinessConcept"],
["pr:monitoredBy","pr:BusinessObject","pr:DataSteward"],
["pr:owningDomain","pr:DefinedObject","pr:DataDomain"],
["pr:ownedBy","pr:DefinedObject","pr:Actor"]].forEach(function(sc){
if(!byId[sc[0]] || byId[sc[0]].deprecated) return;
if((FROM[sc[1]]||[]).indexOf(sc[0])===-1) (FROM[sc[1]]=FROM[sc[1]]||[]).push(sc[0]);
if((TO[sc[2]]||[]).indexOf(sc[0])===-1) (TO[sc[2]]=TO[sc[2]]||[]).push(sc[0]);
});
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");
}
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(n.shortLabel || clean(n.label))+'</h3>');
if(n.shortLabel && n.shortLabel !== clean(n.label))
h.push('<div class="row" style="border:0;padding:0 0 6px"><span class="mono">'
+ esc(clean(n.label)) + '</span><span></span></div>');
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("Authoring mode", (n.authoring||"").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("Authoring mode", (n.authoring||"").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 */
h.push('<h4>What it is for</h4><p>' + (n.comment
? esc(n.comment).replace(/\n/g,"<br>")
: '<em style="color:var(--muted)">No definition yet \u2014 this term needs one '
+ 'before it can be published.</em>') + '</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){
/* deprecated terms live on their own tab; showing them in a link list
invites someone to use one */
ids=(ids||[]).filter(function(i){
return byId[i] && (!byId[i].deprecated || label === "Replaced by"); });
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("Type of", [n.parent, n.subPropertyOf]);
link("Types", 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(); } }; });
}
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"); }; });
}
function bindRows(){
host.querySelectorAll("tr[data-id]").forEach(function(tr){
tr.onclick=function(){ open(tr.dataset.id); }; });
}
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();
</script>
</body>
</html>