Files
data-meta-model/scripts/generate_tbox_viewer.py
T

1306 lines
60 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
PR Data Meta Model - T-Box viewer generator
============================================
Reads the T-Box Turtle file and emits a self-contained HTML view.
Build artefact: regenerate, never edit.
USAGE
python3 scripts/generate_tbox_viewer.py [in.ttl] [out.html]
defaults: ontology/pr_metamodel.ttl -> generated/pr_metamodel_viewer.html
Same visual language as the SODH viewer, with one substitution. Where SODH
lays its columns out by owning domain, the T-Box has no domains -- its
orthogonal axis is PROVENANCE: what governance asserts against what a
harvester observes. The two-colour code carries that instead, blue for
defined and dark gold for captured, and the seam is what crosses between them.
No dependency: parses the Turtle subset this file uses, so it runs on the DSM
system python without a venv.
"""
import json
import os
import re
import sys
from collections import OrderedDict
PR = "https://ontology.pernod-ricard.com/metamodel/"
WANTED = {
"a": "type", "rdf:type": "type",
"rdfs:label": "label", "rdfs:comment": "comment",
"rdfs:subClassOf": "subClassOf", "rdfs:subPropertyOf": "subPropertyOf",
"rdfs:domain": "domain", "rdfs:range": "range",
"owl:inverseOf": "inverseOf", "owl:deprecated": "deprecated",
"dcterms:isReplacedBy": "isReplacedBy",
"pr:harvestSource": "harvestSource", "pr:authoringMode": "authoringMode",
"pr:hasAcronym": "acronym", "pr:hasShortLabel": "shortLabel",
}
LAYER_ROOT = OrderedDict([
("pr:OwnershipLayerObject", ("ownership", "Ownership & Categorization",
"who is accountable")),
("pr:BusinessLayerObject", ("business", "Business", "what it means")),
("pr:LogicalLayerObject", ("logical", "Logical",
"how it is structured")),
("pr:DeliveryLayerObject", ("delivery", "Delivery",
"what we promise, to whom, until when")),
("pr:PhysicalLayerObject", ("physical", "Physical",
"what actually runs")),
("pr:ConsumptionLayerObject", ("consumption", "Consumption",
"what people open in the morning")),
])
# The only relations crossing between what is asserted and what is harvested.
SEAM = {"pr:materializedAs", "pr:storedIn", "pr:physicalizedIn"}
# ---------------------------------------------------------------- parsing
def strip_comments(text):
out, i, n = [], 0, len(text)
while i < n:
c = text[i]
if c == '"':
j = i + 1
while j < n and text[j] != '"':
j += 2 if text[j] == "\\" else 1
j = min(j + 1, n)
out.append(text[i:j]); i = j; continue
if c == "#":
j = text.find("\n", i)
i = n if j == -1 else j
continue
out.append(c); i += 1
return "".join(out)
def split_outside(text, sep):
parts, buf, i, n = [], [], 0, len(text)
while i < n:
c = text[i]
if c == '"':
j = i + 1
while j < n and text[j] != '"':
j += 2 if text[j] == "\\" else 1
j = min(j + 1, n)
buf.append(text[i:j]); i = j; continue
if c == sep:
parts.append("".join(buf)); buf = []; i += 1; continue
buf.append(c); i += 1
parts.append("".join(buf))
return parts
def unquote(tok):
tok = tok.strip()
if tok.startswith('"'):
end = tok.rindex('"')
return tok[1:end].replace('\\"', '"').replace("\\\\", "\\")
return tok
def parse(path):
"""Turtle subset reader: enough for an instance file, keeps ordering."""
text = strip_comments(open(path, encoding="utf-8").read())
subjects = OrderedDict()
for stmt in split_outside(text, "."):
stmt = stmt.strip()
if not stmt or not stmt.startswith("ex:"):
continue
clauses = split_outside(stmt, ";")
head = clauses[0].strip()
m = re.match(r"^(\S+)\s+(.*)$", head, re.S)
if not m:
continue
subj, rest = m.group(1), m.group(2)
rec = subjects.setdefault(subj, {"id": subj})
for chunk in [rest] + [c.strip() for c in clauses[1:]]:
chunk = chunk.strip()
if not chunk:
continue
pm = re.match(r"^(\S+)\s+(.*)$", chunk, re.S)
if not pm:
continue
pred, objs = pm.group(1), pm.group(2)
key = "type" if pred in ("a", "rdf:type") else pred.split(":")[-1]
vals = [unquote(t) for t in split_outside(objs, ",") if t.strip()]
if key in MULTI or key in rec:
rec.setdefault(key, [])
if not isinstance(rec[key], list):
rec[key] = [rec[key]]
rec[key] += vals
else:
rec[key] = vals[0] if len(vals) == 1 else vals
return subjects
def as_list(v):
if v is None:
return []
return v if isinstance(v, list) else [v]
# ---------------------------------------------------------------- model
def first(rec, key, default=""):
v = rec.get(key)
return v[0] if v else default
def parse_tbox(path):
"""Turtle subset reader for the T-Box: pr: subjects, WANTED predicates."""
text = strip_comments(open(path, encoding="utf-8").read())
subjects = OrderedDict()
for stmt in split_outside(text, "."):
stmt = stmt.strip()
if not stmt.startswith("pr:"):
continue
chunks = split_outside(stmt, ";")
m = re.match(r"^(\S+)\s+(.*)$", chunks[0].strip(), re.S)
if not m:
continue
rec = subjects.setdefault(m.group(1), {})
for chunk in [m.group(2)] + [c.strip() for c in chunks[1:]]:
chunk = chunk.strip()
if not chunk:
continue
pm = re.match(r"^(\S+)\s+(.*)$", chunk, re.S)
if not pm:
continue
key = WANTED.get(pm.group(1))
if key:
rec.setdefault(key, []).extend(
unquote(t) for t in split_outside(pm.group(2), ",") if t.strip())
return subjects
def build(subjects):
classes, objp, datp, annp, individuals = {}, {}, {}, {}, {}
for term, rec in subjects.items():
types = rec.get("type", [])
if "owl:Class" in types:
classes[term] = rec
elif "owl:ObjectProperty" in types:
objp[term] = rec
elif "owl:DatatypeProperty" in types:
datp[term] = rec
elif "owl:AnnotationProperty" in types:
annp[term] = rec
elif types and types[0].startswith("pr:"):
individuals.setdefault(types[0], []).append(term)
def ancestry(term, seen=None):
seen = seen or set()
if term in seen:
return []
seen.add(term)
out = [term]
for parent in classes.get(term, {}).get("subClassOf", []):
out += ancestry(parent, seen)
return out
parents = {c: first(classes[c], "subClassOf") for c in classes}
has_kids = set(parents.values())
nodes = {}
for term, rec in classes.items():
anc = ancestry(term)
layer = next((LAYER_ROOT[a][0] for a in anc if a in LAYER_ROOT), None)
if layer is None:
if "pr:Actor" in anc:
layer = "actors"
elif term in ("pr:ActivationStatus", "pr:Environment", "pr:SystemType"):
layer = "vocab"
else:
layer = "roots"
# provenance is INFERRED from the type-of chain, never asserted:
# a Sub-domain is Defined because it is a kind of Ownership Layer
# Object, which is a kind of Defined Object. No separate edge exists
# because none is needed -- the subclass chain already says it.
prov = ("captured" if "pr:CapturedObject" in anc
else "defined" if "pr:DefinedObject" in anc else "structural")
depth, p, guard = 0, parents.get(term), 0
while p and p in classes and guard < 8:
depth += 1; guard += 1; p = parents.get(p)
nodes[term] = {
"id": term, "kind": "Class",
"label": first(rec, "label", term.split(":")[1]),
"shortLabel": first(rec, "shortLabel"),
"layer": layer, "prov": prov, "depth": depth,
"parent": parents.get(term, ""),
"comment": first(rec, "comment"),
"harvest": first(rec, "harvestSource"),
"authoring": first(rec, "authoringMode"),
"acronym": first(rec, "acronym"),
"deprecated": first(rec, "deprecated") in ("true", "True"),
"replacedBy": first(rec, "isReplacedBy"),
"abstract": term in has_kids and term not in LAYER_ROOT
and term not in ("pr:MetaModelObject",),
"members": [{"id": m, "label": first(subjects.get(m, {}), "label",
m.split(":")[1])}
for m in individuals.get(term, [])],
}
def prop(term, rec, kind):
return {
"id": term, "kind": kind,
"label": first(rec, "label", term.split(":")[1]),
"shortLabel": first(rec, "shortLabel"),
"comment": first(rec, "comment"),
"domain": first(rec, "domain"), "range": first(rec, "range"),
"inverseOf": first(rec, "inverseOf"),
"subPropertyOf": first(rec, "subPropertyOf"),
"authoring": first(rec, "authoringMode"),
"harvest": first(rec, "harvestSource"),
"functional": "owl:FunctionalProperty" in rec.get("type", []),
"transitive": "owl:TransitiveProperty" in rec.get("type", []),
"deprecated": first(rec, "deprecated") in ("true", "True"),
"replacedBy": first(rec, "isReplacedBy"),
"seam": term in SEAM,
"prov": "", "layer": "",
}
layers = [{"id": v[0], "title": v[1], "tag": v[2]} for v in LAYER_ROOT.values()]
layers = ([{"id": "roots", "title": "Model roots", "tag": "how a term got here"}]
+ layers
+ [{"id": "actors", "title": "Actors", "tag": "people and teams"},
{"id": "vocab", "title": "Vocabularies", "tag": "controlled value lists"}])
return {"nodes": list(nodes.values()),
"relations": [prop(t, r, "Relation") for t, r in objp.items()],
"attributes": [prop(t, r, "Attribute") for t, r in datp.items()],
"bridges": [prop(t, r, "Bridge") for t, r in annp.items()
if t.startswith("pr:denotes")],
"layers": layers}
# ---------------------------------------------------------------- output
def main():
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
src = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
root, "ontology", "pr_metamodel.ttl")
out = sys.argv[2] if len(sys.argv) > 2 else os.path.join(
root, "generated", "pr_metamodel_viewer.html")
model = build(parse_tbox(src))
payload = json.dumps(model, ensure_ascii=False, separators=(",", ":"))
html = TEMPLATE.replace("__MODEL__", payload).replace(
"__SRC__", os.path.basename(src))
os.makedirs(os.path.dirname(out), exist_ok=True)
open(out, "w", encoding="utf-8").write(html)
live_c = [n for n in model["nodes"] if not n["deprecated"]]
dep = ([n for n in model["nodes"] if n["deprecated"]]
+ [r for r in model["relations"] + model["attributes"] if r["deprecated"]])
print("PR META MODEL - T-Box viewer")
print(" source : %s" % src)
print(" output : %s" % out)
print(" classes %d | relations %d | attributes %d | bridges %d | deprecated %d"
% (len(live_c),
len([r for r in model["relations"] if not r["deprecated"]]),
len([a for a in model["attributes"] if not a["deprecated"]]),
len(model["bridges"]), len(dep)))
for l in model["layers"]:
c = len([n for n in live_c if n["layer"] == l["id"]])
if c:
print(" %-28s %d" % (l["title"], c))
for p in ("defined", "captured", "structural"):
c = len([n for n in live_c if n["prov"] == p])
print(" %-28s %d" % (p, c))
TEMPLATE = r"""<!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 asserts and what a harvester observes</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">Relations</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 __SRC__ &mdash; regenerate, never edit</span>
<span id="f-count"></span>
</footer>
<script>
var M = __MODEL__;
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:ForeignKeyConstraint", "pr:Transformation",
"pr:ConsumptionLayerObject", "pr:BIWorkspace", "pr:BIDataSource", "pr:BIField",
"pr:BIReport"];
var LANES = [["defined","Defined \u00b7 someone decided it","#1E4B9B"],
["captured","Captured \u00b7 read from a running system","#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});
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"});
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, 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);
});
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";
}
function wrapText(s,max){
var w=s.split(" "), out=[], cur="";
w.forEach(function(x){
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);
}
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;
g.sort(function(a,b){
return a.depth - b.depth
|| ((ORDER.indexOf(a.id)+1)||99) - ((ORDER.indexOf(b.id)+1)||99)
|| 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><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>');
});
return h.join("");
}
/* ------------------------------------------------------------ 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("");
}
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.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="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("");
}
/* ------------------------------------------------------------ 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>
"""
if __name__ == "__main__":
main()