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

1228 lines
56 KiB
Python

#!/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
curated and dark gold for observed, 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:curationMode": "curationMode",
"pr:hasAcronym": "acronym",
}
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")),
("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"
prov = ("observed" if "pr:ObservedObject" in anc
else "curated" if "pr:CuratedObject" 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]),
"layer": layer, "prov": prov, "depth": depth,
"parent": parents.get(term, ""),
"comment": first(rec, "comment"),
"harvest": first(rec, "harvestSource"),
"curation": first(rec, "curationMode"),
"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]),
"comment": first(rec, "comment"),
"domain": first(rec, "domain"), "range": first(rec, "range"),
"inverseOf": first(rec, "inverseOf"),
"subPropertyOf": first(rec, "subPropertyOf"),
"curation": first(rec, "curationMode"),
"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 ("curated", "observed", "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 === "observed" ? "#8A6A28" : p === "curated" ? "#1E4B9B" : "#6B6B6B";
}
var LANES = [["curated","Curated \u00b7 asserted by governance","#1E4B9B"],
["observed","Observed \u00b7 harvested nightly","#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;
keep[n.id] = {n:n, x:0, y:0,
layer: M.layers.map(function(l){return l.id;}).indexOf(n.layer),
row: Math.min(n.depth, 2)};
});
var E = [];
live(M.relations).forEach(function(r){
if(keep[r.domain] && keep[r.range] && r.domain !== r.range)
E.push({s:r.domain, t:r.range, l:r.label, cross:r.seam});
});
live(M.nodes).forEach(function(n){
if(n.parent && keep[n.id] && keep[n.parent])
E.push({s:n.id, t:n.parent, l:"specialises", cross:false});
});
G.nodes = Object.keys(keep).map(function(k){ return keep[k]; });
G.edges = E;
return keep;
}
/* Strata = a matrix. Columns are domains, rows are meta model layers.
Everything owned by one domain sits in one vertical lane, so a cross-domain
dependency is literally a line leaving its column. */
function layoutGraph(idx){
if(G.mode === "network"){ layoutForce(idx); return; }
var lanes = LANES.map(function(L){ return L[0]; })
.filter(function(c){ return G.nodes.some(function(n){ return n.n.prov === c; }); });
var laneW = {}, x = GUT;
G._lanes = [];
lanes.forEach(function(code){
var members = G.nodes.filter(function(n){ return n.n.prov === code; });
var widest = 1;
M.layers.forEach(function(_, li){
[0,1,2].forEach(function(ri){
var c = members.filter(function(n){ return n.layer===li && n.row===ri; }).length;
if(c) widest = Math.max(widest, Math.min(c, Math.ceil(Math.sqrt(c*1.6))));
});
});
var w = widest*(NW+12) + LANEPAD*2;
laneW[code] = {x:x, w:w, per:widest};
var L = LANES.filter(function(z){ return z[0] === code; })[0];
G._lanes.push({code:code, x:x, w:w, label:L[1], colour:L[2]});
x += w;
});
G._width = x;
var y = 0;
M.layers.forEach(function(b, li){
b._y = y;
var maxRows = 0;
var rowTops = {};
var cursor = y + 34;
[0,1,2].forEach(function(ri){
var any = G.nodes.filter(function(n){ return n.layer===li && n.row===ri; });
if(!any.length){ rowTops[ri] = cursor; return; }
rowTops[ri] = cursor;
var deepest = 0;
lanes.forEach(function(code){
var g = any.filter(function(n){ return n.n.prov === code; });
if(!g.length) return;
var per = laneW[code].per;
deepest = Math.max(deepest, Math.ceil(g.length/per));
g.sort(function(p,q){ return p.n.label.localeCompare(q.n.label); });
g.forEach(function(n, i){
n.x = laneW[code].x + LANEPAD + (i%per)*(NW+12) + NW/2;
n.y = cursor + Math.floor(i/per)*(NH+ROWGAP) + NH/2;
});
});
cursor += deepest*(NH+ROWGAP) + ROWGAP;
maxRows += deepest;
});
b._h = maxRows ? (cursor - y + 12) : 0;
y += b._h + (b._h ? BANDGAP : 0);
});
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 === "curated";
bL.appendChild(el("rect", {x:l.x, y:-6, width:l.w, height:G._height+10,
fill:l.colour, opacity:isSubject ? .05 : .028}));
if(i) bL.appendChild(el("line", {x1:l.x, y1:-6, x2:l.x, y2:G._height+4,
stroke:"#D8D4C8", "stroke-width":1}));
var t = el("text", {x:l.x+l.w/2, y:-16, "text-anchor":"middle",
class:"lanelabel", fill:l.colour});
t.textContent = l.label;
bL.appendChild(t);
});
M.layers.forEach(function(b){
if(!b._h) return;
bL.appendChild(el("line", {x1:0, y1:b._y, x2:(G._width||900)+10, y2:b._y,
stroke:"#CFCABA", "stroke-width":1}));
var t=el("text",{x:GUT-16, y:b._y+18, class:"bandlabel", "text-anchor":"end",
fill:"#0A1F44"}); t.textContent=b.title; bL.appendChild(t);
var g=el("text",{x:GUT-16, y:b._y+31, class:"bandtag", "text-anchor":"end"});
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.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==="curated";});
var obs=cls.filter(function(n){return n.prov==="observed";});
var str=cls.filter(function(n){return n.prov==="structural";});
var seam=rel.filter(function(r){return r.seam;});
var dep=M.nodes.filter(function(n){return n.deprecated;})
.concat(M.relations.concat(M.attributes).filter(function(r){return r.deprecated;}));
var h=[];
h.push('<p class="lede">The layer says what an object <b>is</b>. The provenance says <b>how it got here</b>.</p>');
h.push('<p class="sublede">Curated objects are asserted by governance and reviewed through a pull request. '
+ 'Observed objects are harvested from Snowflake, Tableau and Looker, rebuilt on every run and never edited '
+ 'by hand. Without that split the ownership rules would demand an owner, a status and a version on every '
+ 'one of some fifty thousand warehouse columns.</p>');
h.push('<div class="kpis">'+kpi(cls.length,"Classes")+kpi(rel.length,"Relations")
+kpi(att.length,"Attributes")+kpi(M.bridges.length,"Bridge properties")
+kpi(seam.length,"Seam edges",true)+kpi(dep.length,"Deprecated",true)+'</div>');
h.push('<h2 class="sec">Curated against observed</h2><div class="bar">'
+'<div style="width:'+(cur.length/cls.length*100)+'%;background:#1E4B9B"></div>'
+'<div style="width:'+(obs.length/cls.length*100)+'%;background:#8A6A28"></div>'
+'<div style="width:'+(str.length/cls.length*100)+'%;background:#B9B4A7"></div></div>');
h.push('<div class="legend">'
+'<span><i style="background:#1E4B9B"></i>Curated \u2014 '+cur.length+'</span>'
+'<span><i style="background:#8A6A28"></i>Observed \u2014 '+obs.length+'</span>'
+'<span><i style="background:#B9B4A7"></i>Structural \u2014 '+str.length+'</span></div>');
h.push('<div class="note" style="max-width:880px;margin-bottom:26px"><b>Only '+seam.length
+' relations cross between the two.</b> '+seam.map(function(r){return esc(r.label);}).join(" and ")
+' \u2014 both proposed by the reconciliation job and confirmed by a steward. Everywhere else the '
+'graph is either pure governance or pure machine, which is what keeps the curation cost bounded.</div>');
h.push('<h2 class="sec">The layers</h2><div class="grid">');
M.layers.forEach(function(l){
var g=cls.filter(function(n){return n.layer===l.id;});
if(!g.length) return;
var c=provColour(g[0].prov);
h.push('<div class="card" data-layer="'+l.id+'"><div class="top" style="background:'+c+'"></div>'
+'<div class="body"><div class="role" style="color:'+c+'">'+esc(l.tag)+'</div>'
+'<h3>'+esc(l.title)+'</h3><p>'+g.slice(0,5).map(function(n){return esc(n.label);}).join(" \u00b7 ")
+(g.length>5?" \u00b7 \u2026":"")+'</p><div class="nums"><div><b>'+g.length+'</b>classes</div>'
+'<div><b>'+rel.filter(function(r){return byId[r.domain]&&byId[r.domain].layer===l.id;}).length
+'</b>relations from</div></div></div></div>');
});
h.push('</div>');
return h.join("");
}
/* ------------------------------------------------------------ LAYERS */
function pill(t,c){ c=c||"#8A6A28";
return '<span class="pill" style="color:'+c+';border-color:'+c+';background:#FBF3E4">'+t+'</span>'; }
function renderModel(){
var h=['<p class="lede">Every class, in the layer it belongs to.</p>'
+'<p class="sublede">Abstract classes are never instantiated \u2014 they exist to give their '
+'specialisations a common parent and a common shape. Superseded terms are kept rather than deleted '
+'so assertions written against them keep classifying; they live on the Deprecated tab.</p>'];
M.layers.forEach(function(l){
var g=live(M.nodes).filter(function(n){return n.layer===l.id;});
if(!g.length) return;
g.sort(function(a,b){ return a.depth-b.depth || a.label.localeCompare(b.label); });
h.push('<h2 class="sec" style="color:'+provColour(g[0].prov)+'">'+esc(l.title)+' \u00b7 '+esc(l.tag)+'</h2>');
h.push('<table><thead><tr><th>Class</th><th>Provenance</th><th class="hide-s">Specialises</th>'
+'<th class="hide-s">What it is for</th></tr></thead><tbody>');
g.forEach(function(n){
h.push('<tr data-id="'+n.id+'"><td><span class="dot" style="background:'+provColour(n.prov)
+'"></span><b>'+esc(n.label)+'</b>'+(n.abstract?' '+pill("abstract"):'')+'</td>'
+'<td class="mono">'+n.prov+'</td><td class="hide-s mono">'
+(byId[n.parent]?esc(clean(byId[n.parent].label)):"\u2014")+'</td>'
+'<td class="hide-s">'+esc((n.comment||"").split(". ")[0])+'</td></tr>');
});
h.push('</tbody></table>');
});
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.curation) t.push(p.curation.toLowerCase().replace(/_/g," "));
h.push('<tr data-id="'+p.id+'"><td><b>'+esc(p.label)+'</b><div class="mono">'+esc(p.id)+'</div></td>'
+'<td class="hide-s">'+(byId[p.domain]?esc(clean(byId[p.domain].label)):'<span class="mono">any</span>')+'</td>'
+'<td class="hide-s">'+(byId[p.range]?esc(clean(byId[p.range].label))
:'<span class="mono">'+esc(p.range||"\u2014")+'</span>')+'</td>'
+'<td class="mono">'+(t.join(" \u00b7 ")||"\u2014")+'</td></tr>');
});
h.push('</tbody></table><p class="sublede" style="margin-top:12px">'+all.length+' shown.</p>');
return h.join("");
}
/* ------------------------------------------------------------ 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">curated</b> on the left, '
+'<b style="color:#8A6A28">observed</b> on the right. Rows are layers. A <b>navy edge</b> crosses '
+'between the two \u2014 that is the seam, the only place a steward confirms anything. Dashed outlines '
+'are abstract classes, never instantiated.</p>'];
h.push('<div class="gwrap"><svg id="g-svg" xmlns="http://www.w3.org/2000/svg"><defs>'
+'<marker id="ga" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto">'
+'<path d="M0,0 L10,5 L0,10 z" fill="#A6ABB5"/></marker>'
+'<marker id="gg" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto">'
+'<path d="M0,0 L10,5 L0,10 z" fill="#0A1F44"/></marker>'
+'<marker id="gn" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto">'
+'<path d="M0,0 L10,5 L0,10 z" fill="#B8935A"/></marker></defs>'
+'<g id="g-vp"><g id="g-b"></g><g id="g-e"></g><g id="g-l"></g><g id="g-n"></g></g></svg>');
h.push('<div class="gctl"><div class="seg"><button id="g-strata" class="on">Strata</button>'
+'<button id="g-network">Network</button></div><div class="gsec">Layers</div>'
+ M.layers.map(function(l){
var n=live(M.nodes).filter(function(x){return x.layer===l.id;}).length;
if(!n) return "";
return '<label><input type="checkbox" data-ly="'+l.id+'"'+(G.layers[l.id]?" checked":"")
+'> '+esc(l.title)+' <span class="mono">'+n+'</span></label>'; }).join("")
+'<div class="gsec">Provenance</div>'
+ LANES.map(function(L){
var n=live(M.nodes).filter(function(x){return x.prov===L[0];}).length;
if(!n) return "";
return '<label><input type="checkbox" data-pv="'+L[0]+'"'
+(!G.provs.length||G.provs.indexOf(L[0])>-1?" checked":"")+'> <i style="background:'+L[2]
+'"></i>'+L[0]+' <span class="mono">'+n+'</span></label>'; }).join("")
+'<div class="gsec">Options</div>'
+'<label><input type="checkbox" id="g-lab" checked> Relation names</label>'
+'<div class="mono" id="g-count" style="margin-top:8px"></div></div>');
h.push('<div class="gzoom"><button id="g-in">+</button><button id="g-out">\u2212</button>'
+'<button id="g-fit" style="font-size:12px">\u21ba</button></div>');
h.push('<div class="ghint">drag to pan \u00b7 scroll to zoom \u00b7 click empty space to clear</div></div>');
return h.join("");
}
function wireGraph(){
renderGraph();
var svg = document.getElementById("g-svg");
function mode(m){
G.mode = m;
document.getElementById("g-strata").classList.toggle("on", m==="strata");
document.getElementById("g-network").classList.toggle("on", m==="network");
renderGraph();
}
document.getElementById("g-strata").onclick = function(){ mode("strata"); };
document.getElementById("g-network").onclick = function(){ mode("network"); };
document.querySelectorAll("[data-ly]").forEach(function(cb){
cb.onchange = function(){ G.layers[cb.dataset.ly] = cb.checked; renderGraph(); };
});
document.getElementById("g-lab").onchange = function(){
G.labels = this.checked; renderGraph(); };
document.querySelectorAll("[data-pv]").forEach(function(cb){
cb.onchange = function(){
var on = [];
document.querySelectorAll("[data-pv]").forEach(function(x){
if(x.checked) on.push(x.dataset.pv); });
G.provs = (on.length === LANES.length) ? [] : on;
renderGraph();
};
});
document.getElementById("g-in").onclick = function(){ zoomG(1.25); };
document.getElementById("g-out").onclick = function(){ zoomG(.8); };
document.getElementById("g-fit").onclick = fitGraph;
var drag=false, moved=false, ds, vs, pinch=null;
svg.addEventListener("mousedown", function(e){
drag=true; moved=false; svg.classList.add("drag");
ds={x:e.clientX,y:e.clientY}; vs={x:G.view.x,y:G.view.y}; });
window.addEventListener("mousemove", function(e){
if(!drag) return;
if(Math.abs(e.clientX-ds.x)+Math.abs(e.clientY-ds.y) > 4) moved=true;
G.view.x=vs.x+(e.clientX-ds.x); G.view.y=vs.y+(e.clientY-ds.y); applyView(); });
window.addEventListener("mouseup", function(){
drag=false; svg.classList.remove("drag"); });
/* click on empty canvas clears the selection -- a highlight that cannot be
dismissed traps the reader in one node */
svg.addEventListener("click", function(e){
if(!moved && !e.target.closest(".gnode")){ clearSel(); insp.classList.remove("open"); }
});
svg.addEventListener("wheel", function(e){ e.preventDefault();
var f=e.deltaY<0?1.1:.9, r=svg.getBoundingClientRect();
var mx=e.clientX-r.left, my=e.clientY-r.top;
var ns=Math.max(.1,Math.min(G.view.s*f,3));
G.view.x=mx-(mx-G.view.x)*(ns/G.view.s); G.view.y=my-(my-G.view.y)*(ns/G.view.s);
G.view.s=ns; applyView(); }, {passive:false});
svg.addEventListener("touchstart", function(e){
if(e.touches.length===1){ drag=true; moved=false;
ds={x:e.touches[0].clientX,y:e.touches[0].clientY}; vs={x:G.view.x,y:G.view.y}; }
else if(e.touches.length===2){ drag=false;
var dx=e.touches[0].clientX-e.touches[1].clientX,
dy=e.touches[0].clientY-e.touches[1].clientY;
pinch={d:Math.sqrt(dx*dx+dy*dy), s:G.view.s}; } }, {passive:true});
svg.addEventListener("touchmove", function(e){
if(e.touches.length===1 && drag){ moved=true;
G.view.x=vs.x+(e.touches[0].clientX-ds.x);
G.view.y=vs.y+(e.touches[0].clientY-ds.y); applyView(); e.preventDefault(); }
else if(e.touches.length===2 && pinch){
var dx=e.touches[0].clientX-e.touches[1].clientX,
dy=e.touches[0].clientY-e.touches[1].clientY;
var nd=Math.sqrt(dx*dx+dy*dy), ns=Math.max(.1,Math.min(pinch.s*(nd/pinch.d),3));
var r=svg.getBoundingClientRect();
G.view.x=r.width/2-(r.width/2-G.view.x)*(ns/G.view.s);
G.view.y=r.height/2-(r.height/2-G.view.y)*(ns/G.view.s);
G.view.s=ns; applyView(); e.preventDefault(); } }, {passive:false});
svg.addEventListener("touchend", function(){ drag=false; pinch=null; });
var nL = document.getElementById("g-n");
nL.addEventListener("click", function(e){
var g = e.target.closest(".gnode"); if(!g) return;
e.stopPropagation();
if(G.sel === g.dataset.id){ clearSel(); insp.classList.remove("open"); return; }
G.sel = g.dataset.id; G.hot = null; applyHot(); openCard(g.dataset.id, true);
});
nL.addEventListener("keydown", function(e){
var g = e.target.closest(".gnode");
if(g && (e.key==="Enter"||e.key===" ")){ e.preventDefault();
G.sel=g.dataset.id; applyHot(); openCard(g.dataset.id, true); } });
nL.addEventListener("mouseover", function(e){
var g=e.target.closest(".gnode"); if(g && !G.sel){ G.hot=g.dataset.id; applyHot(); } });
nL.addEventListener("mouseout", function(e){
if(!G.sel && (!e.relatedTarget || !e.relatedTarget.closest
|| !e.relatedTarget.closest(".gnode"))){ G.hot=null; applyHot(); } });
window.addEventListener("resize", fitGraph);
}
/* ------------------------------------------------------------ INSPECTOR */
var insp=document.getElementById("insp"), ibody=document.getElementById("insp-body");
var HIST=[];
var CHILDREN={}, FROM={}, TO={}, ATTRS={};
M.nodes.forEach(function(n){
if(n.parent) (CHILDREN[n.parent]=CHILDREN[n.parent]||[]).push(n.id); });
M.relations.forEach(function(r){
if(r.deprecated) return;
if(r.domain) (FROM[r.domain]=FROM[r.domain]||[]).push(r.id);
if(r.range) (TO[r.range]=TO[r.range]||[]).push(r.id); });
M.attributes.forEach(function(a){
if(a.deprecated||!a.domain) return;
(ATTRS[a.domain]=ATTRS[a.domain]||[]).push(a.id); });
function openCard(id, reset){
if(reset) HIST=[];
var cur=HIST[HIST.length-1];
if(cur&&cur!==id) HIST.push(id); else if(!cur) HIST.push(id);
paint(id);
}
function back(){
HIST.pop();
var prev=HIST[HIST.length-1];
if(prev){ paint(prev); if(G.sel){ G.sel=prev; applyHot(); } }
else insp.classList.remove("open");
}
function open(id){ openCard(id,true); }
function paint(id){
var n=byId[id]; if(!n) return;
var h=[];
if(HIST.length>1)
h.push('<button class="backlink" id="i-back">\u2190 back to '
+ esc(clean(byId[HIST[HIST.length-2]].label)) + '</button>');
var kindLabel = n.kind==="Class" ? "Class" : n.kind==="Relation" ? "Relation"
: n.kind==="Attribute" ? "Attribute" : "Bridge property";
h.push('<div class="kind" style="color:'+provColour(n.prov||"structural")+'">'
+ kindLabel + (n.layer ? ' \u00b7 ' + esc(layerTitle(n.layer)) : '') + '</div>');
h.push('<h3>'+esc(clean(n.label))+'</h3>');
h.push('<div class="iri">'+esc(n.id)+'</div>');
if(n.deprecated) h.push('<div class="note"><b>Deprecated.</b> Kept so assertions written '
+ 'against it keep classifying (LC-002)'
+ (byId[n.replacedBy] ? ', replaced by <b>'+esc(clean(byId[n.replacedBy].label))+'</b>' : '')
+ '. Not to be used in new work.</div>');
/* 1. attributes, always first and always in the same place */
var rows=[];
function at(l,v){ if(v) rows.push([l,v]); }
if(n.kind==="Class"){
at("Provenance", n.prov);
at("Instantiated", n.abstract ? "never \u2014 abstract" : "yes");
at("Curation mode", (n.curation||"").toLowerCase().replace(/_/g," "));
at("Acronym", n.acronym);
} else {
at("Cardinality", n.functional ? "exactly one" : "many");
at("Transitive", n.transitive ? "yes" : "");
at("Crosses the seam", n.seam ? "yes" : "");
at("Curation mode", (n.curation||"").toLowerCase().replace(/_/g," "));
if(!byId[n.range]) at("Range", n.range);
if(!n.domain) at("Domain", "any \u2014 scoped by SHACL per class");
}
if(rows.length) h.push('<h4>Attributes</h4>'+rows.map(function(r){
return '<div class="row"><span>'+r[0]+'</span><span class="mono">'+esc(r[1])+'</span></div>';
}).join(""));
/* 2. what it is for */
if(n.comment) h.push('<h4>What it is for</h4><p>'+esc(n.comment).replace(/\n/g,"<br>")+'</p>');
if(n.harvest) h.push('<h4>Harvested from</h4><div class="harv">'+esc(n.harvest)+'</div>');
/* 3. related terms, from the coarsest down */
function link(label, ids){
ids=(ids||[]).filter(function(i){return byId[i];});
if(!ids.length) return;
h.push('<h4>'+label+'</h4>');
ids.slice(0,16).forEach(function(i){
h.push('<div class="row"><button class="jump" data-j="'+i+'">'+esc(clean(byId[i].label))
+'</button><span class="mono">'+(byId[i].deprecated?"deprecated"
:byId[i].kind==="Class"?(byId[i].prov||""):byId[i].kind.toLowerCase())+'</span></div>');
});
if(ids.length>16) h.push('<div class="row"><span class="mono">\u2026 and '
+(ids.length-16)+' more</span><span></span></div>');
}
link("Specialises", [n.parent, n.subPropertyOf]);
link("Specialised by", CHILDREN[n.id]);
link("Replaced by", [n.replacedBy]);
link("From", [n.domain]);
link("To", [n.range]);
link("Inverse of", [n.inverseOf]);
link("Relations from here", (FROM[n.id]||[]));
link("Relations to here", (TO[n.id]||[]));
link("Attributes carried", ATTRS[n.id]);
if(n.members && n.members.length)
h.push('<h4>Allowed values</h4>'+n.members.map(function(m){
return '<div class="row"><span>'+esc(m.label)+'</span><span class="mono">'
+esc(m.id)+'</span></div>'; }).join(""));
ibody.innerHTML=h.join("");
insp.classList.add("open");
var bk=document.getElementById("i-back"); if(bk) bk.onclick=back;
ibody.querySelectorAll(".jump").forEach(function(b){
b.onclick=function(){ HIST.push(b.dataset.j); paint(b.dataset.j);
if(G.sel){ G.sel=b.dataset.j; applyHot(); } }; });
}
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()