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

980 lines
41 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, zero-dependency HTML
viewer. The TTL is the source of truth; the viewer is a build artefact and must
never be edited by hand.
USAGE
python generate_tbox_viewer.py [tbox.ttl] [out.html]
Defaults: pr_datametamodel_v1_0.ttl -> pr_metamodel_viewer_v1_0.html
Uses rdflib when available; otherwise falls back to a restricted Turtle reader
that handles the subset of syntax used by this T-Box. Run it in CI on every
merge so the viewer can never drift from the model.
"""
import json
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",
}
# --------------------------------------------------------------------------
# Turtle reading
# --------------------------------------------------------------------------
def strip_comments(text):
"""Remove # comments that sit outside string literals."""
out, i, n = [], 0, len(text)
while i < n:
c = text[i]
if text.startswith('"""', i):
j = text.find('"""', i + 3)
j = n if j == -1 else j + 3
out.append(text[i:j]); i = j; continue
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_top(text, sep):
"""Split on `sep` at nesting depth 0, ignoring string literals."""
parts, buf, depth, i, n = [], [], 0, 0, len(text)
while i < n:
c = text[i]
if text.startswith('"""', i):
j = text.find('"""', i + 3)
j = n if j == -1 else j + 3
buf.append(text[i:j]); i = j; continue
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 in "([":
depth += 1
elif c in ")]":
depth -= 1
if c == sep and depth == 0:
parts.append("".join(buf)); buf = []; i += 1; continue
buf.append(c); i += 1
if "".join(buf).strip():
parts.append("".join(buf))
return parts
def tokens(text):
"""Split an object list into individual terms."""
return [t.strip() for t in split_top(text, ",") if t.strip()]
def unquote(tok):
tok = tok.strip()
if tok.startswith('"""'):
return tok[3:tok.rindex('"""')] if tok.count('"""') > 1 else tok[3:]
if tok.startswith('"'):
end = tok.rindex('"')
return tok[1:end] if end > 0 else tok[1:]
return tok
def parse_ttl_fallback(text):
text = strip_comments(text)
subjects = OrderedDict()
for stmt in split_top(text, "."):
stmt = stmt.strip()
if not stmt:
continue
chunks = split_top(stmt, ";")
head = chunks[0].strip()
m = re.match(r"^(\S+)\s+(.*)$", head, re.S)
if not m:
continue
subj, rest = m.group(1), m.group(2)
if not subj.startswith("pr:"):
continue
rec = subjects.setdefault(subj, {})
for chunk in [rest] + [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
pred, objs = pm.group(1), pm.group(2)
key = WANTED.get(pred)
if not key:
continue
rec.setdefault(key, []).extend(unquote(t) for t in tokens(objs))
return subjects
def parse_ttl_rdflib(path):
from rdflib import Graph, RDF, RDFS, OWL, URIRef, Namespace
DCT = Namespace("http://purl.org/dc/terms/")
g = Graph(); g.parse(path, format="turtle")
inv = {
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",
DCT.isReplacedBy: "isReplacedBy",
URIRef(PR + "harvestSource"): "harvestSource",
URIRef(PR + "curationMode"): "curationMode",
}
def short(term):
s = str(term)
for pfx, ns in (("pr:", PR),
("owl:", "http://www.w3.org/2002/07/owl#"),
("rdfs:", "http://www.w3.org/2000/01/rdf-schema#"),
("xsd:", "http://www.w3.org/2001/XMLSchema#"),
("prov:", "http://www.w3.org/ns/prov#")):
if s.startswith(ns):
return pfx + s[len(ns):]
return s
subjects = OrderedDict()
for s, p, o in g:
key = inv.get(p)
if key is None or not str(s).startswith(PR):
continue
rec = subjects.setdefault(short(s), {})
rec.setdefault(key, []).append(str(o) if key in ("label", "comment", "harvestSource", "curationMode", "deprecated") else short(o))
return subjects
def read_tbox(path):
try:
return parse_ttl_rdflib(path), "rdflib"
except ImportError:
with open(path, encoding="utf-8") as fh:
return parse_ttl_fallback(fh.read()), "built-in reader"
# --------------------------------------------------------------------------
# Model assembly
# --------------------------------------------------------------------------
BANDS = [
("roots", "Model roots", "How a term got here", "#0A1F44"),
("ownership", "Ownership & Categorization", "Who is accountable", "#B8935A"),
("business", "Business", "What the business means", "#8B3A3A"),
("logical", "Logical", "What we commit to deliver", "#7A4E8B"),
("physical", "Physical", "What actually runs", "#1E4B9B"),
("consumption", "Consumption", "What people open in the morning", "#1E7A5F"),
("context", "Actors & vocabularies", "Outside the object stack", "#6B6B6B"),
]
LAYER_ROOT = {
"pr:OwnershipLayerObject": "ownership",
"pr:BusinessLayerObject": "business",
"pr:LogicalLayerObject": "logical",
"pr:PhysicalLayerObject": "physical",
"pr:ConsumptionLayerObject": "consumption",
}
def first(rec, key, default=""):
v = rec.get(key)
return v[0] if v else default
def build(subjects):
classes, objprops, dataprops, annprops, individuals = {}, {}, {}, {}, {}
for term, rec in subjects.items():
types = rec.get("type", [])
if "owl:Class" in types:
classes[term] = rec
elif "owl:ObjectProperty" in types:
objprops[term] = rec
elif "owl:DatatypeProperty" in types:
dataprops[term] = rec
elif "owl:AnnotationProperty" in types:
annprops[term] = rec
elif types and types[0].startswith("pr:"):
individuals.setdefault(types[0], []).append(term)
# layer + provenance by walking subClassOf upward
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
nodes = []
for term, rec in classes.items():
anc = ancestry(term)
band = next((LAYER_ROOT[a] for a in anc if a in LAYER_ROOT), None)
if band is None:
if "pr:Actor" in anc or term in ("pr:ActivationStatus", "pr:Environment", "pr:SystemType"):
band = "context"
else:
band = "roots"
provenance = ("observed" if "pr:ObservedObject" in anc
else "curated" if "pr:CuratedObject" in anc else "neutral")
nodes.append({
"id": term,
"label": first(rec, "label", term.split(":")[1]),
"band": band,
"prov": provenance,
"parent": first(rec, "subClassOf"),
"comment": first(rec, "comment"),
"harvest": first(rec, "harvestSource"),
"curation": first(rec, "curationMode"),
"deprecated": first(rec, "deprecated") in ("true", "True"),
"replacedBy": first(rec, "isReplacedBy"),
"members": [
{"id": m, "label": first(subjects.get(m, {}), "label", m.split(":")[1])}
for m in individuals.get(term, [])
],
})
known = {n["id"] for n in nodes}
edges, universal = [], []
for term, rec in objprops.items():
dom, rng = first(rec, "domain"), first(rec, "range")
item = {
"id": term,
"label": first(rec, "label", term.split(":")[1]),
"s": dom, "t": rng,
"comment": first(rec, "comment"),
"harvest": first(rec, "harvestSource"),
"curation": first(rec, "curationMode"),
"inverseOf": first(rec, "inverseOf"),
"subPropertyOf": first(rec, "subPropertyOf"),
"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"),
}
if dom in known and rng in known:
edges.append(item)
else:
item["s"] = dom or "pr:MetaModelObject"
universal.append(item)
datatypes = [{
"id": t, "label": first(r, "label", t.split(":")[1]),
"range": first(r, "range"), "comment": first(r, "comment"),
"harvest": first(r, "harvestSource"),
"functional": "owl:FunctionalProperty" in r.get("type", []),
"deprecated": first(r, "deprecated") in ("true", "True"),
"replacedBy": first(r, "isReplacedBy"),
} for t, r in dataprops.items()]
bridges = [{
"id": t, "label": first(r, "label", t.split(":")[1]),
"comment": first(r, "comment"),
} for t, r in annprops.items() if t.startswith("pr:denotes")]
onto = subjects.get("<https://ontology.pernod-ricard.com/metamodel/>", {})
return {
"bands": [{"id": b[0], "title": b[1], "tag": b[2], "color": b[3]} for b in BANDS],
"nodes": nodes, "edges": edges, "universal": universal,
"datatypes": datatypes, "bridges": bridges,
}
# --------------------------------------------------------------------------
# HTML emission
# --------------------------------------------------------------------------
def emit(model, version, src_name, out_path):
payload = json.dumps(model, ensure_ascii=False, separators=(",", ":"))
html = (TEMPLATE.replace("__MODEL__", payload)
.replace("__SRC__", src_name)
.replace("__VERSION__", version))
with open(out_path, "w", encoding="utf-8") as fh:
fh.write(html)
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 __VERSION__</title>
<style>
:root{
--navy:#0A1F44; --gold:#B8935A; --ink:#1A1A1A;
--bg:#F7F6F2; --panel:#FFFFFF; --rule:#E4E2DA; --muted:#6B6B6B;
--c-roots:#0A1F44; --c-ownership:#B8935A; --c-business:#8B3A3A;
--c-logical:#7A4E8B; --c-physical:#1E4B9B; --c-consumption:#1E7A5F;
--c-context:#6B6B6B;
--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{height:100%;margin:0}
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}
/* ---------- chrome ---------- */
header{background:var(--navy);color:#fff;padding:10px 18px;display:flex;
align-items:center;gap:16px;flex-shrink:0;box-shadow:0 2px 6px rgba(0,0,0,.15)}
header h1{margin:0;font-family:var(--mono);font-size:13px;font-weight:600;
letter-spacing:.16em;text-transform:uppercase}
header .sub{font-size:11px;color:rgba(255,255,255,.62);margin-top:3px}
.stats{margin-left:auto;display:flex;gap:20px}
.stat{text-align:right;line-height:1.15}
.stat b{display:block;font-family:var(--mono);font-size:16px;color:var(--gold)}
.stat span{font-size:9px;text-transform:uppercase;letter-spacing:.09em;
color:rgba(255,255,255,.55)}
#mtoggle{display:none;background:transparent;color:#fff;
border:1px solid rgba(255,255,255,.4);padding:5px 10px;border-radius:3px;
font-size:11px;font-weight:600;cursor:pointer;font-family:inherit}
main{flex:1;display:flex;min-height:0;position:relative}
/* ---------- sidebar ---------- */
aside{width:255px;background:var(--panel);border-right:1px solid var(--rule);
overflow-y:auto;padding:16px;flex-shrink:0}
aside h2{font-family:var(--mono);font-size:9.5px;text-transform:uppercase;
letter-spacing:.14em;color:var(--muted);margin:22px 0 9px;font-weight:600}
aside h2:first-child{margin-top:0}
#q{width:100%;padding:8px 10px;border:1px solid var(--rule);border-radius:3px;
font-family:var(--mono);font-size:12px;background:var(--bg)}
#q:focus{border-color:var(--navy);outline:none;background:#fff}
.chip{display:flex;align-items:center;gap:9px;padding:5px 0;font-size:12.5px;
cursor:pointer;user-select:none;border:none;background:none;width:100%;
text-align:left;font-family:inherit;color:inherit}
.chip .swatch{width:11px;height:11px;flex-shrink:0;border-radius:2px}
.chip .n{margin-left:auto;font-family:var(--mono);font-size:10px;color:var(--muted)}
.chip.off{opacity:.34}
.seg{display:flex;border:1px solid var(--rule);border-radius:3px;overflow:hidden}
.seg button{flex:1;padding:7px 4px;background:#fff;border:none;cursor:pointer;
font-family:var(--mono);font-size:9.5px;letter-spacing:.07em;
text-transform:uppercase;color:var(--muted)}
.seg button+button{border-left:1px solid var(--rule)}
.seg button.on{background:var(--navy);color:#fff}
.opt{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:12px;cursor:pointer}
.opt input{accent-color:var(--navy);width:15px;height:15px;cursor:pointer}
.note{font-size:11px;line-height:1.55;color:var(--muted)}
.note code{font-family:var(--mono);font-size:10.5px;background:var(--bg);padding:1px 4px}
/* ---------- canvas ---------- */
#wrap{flex:1;min-width:0;position:relative;overflow:hidden;touch-action:none;
background:
linear-gradient(90deg,rgba(10,31,68,.035) 1px,transparent 1px) 0 0/28px 28px,
linear-gradient(180deg,#FCFBF7,#EFEDE3)}
svg{display:block;width:100%;height:100%;cursor:grab}
svg.drag{cursor:grabbing}
.bandrect{opacity:.5}
.bandline{stroke:var(--rule);stroke-width:1}
.bandlabel{font-family:var(--mono);font-size:10px;letter-spacing:.15em;
text-transform:uppercase;font-weight:600}
.bandtag{font-family:var(--sans);font-size:9.5px;fill:var(--muted);font-style:italic}
.node{cursor:pointer}
.node rect{stroke-width:1.5}
.node text{font-size:10px;pointer-events:none;text-anchor:middle;font-weight:500}
.node.dim{opacity:.13}
.node.sel rect{stroke-width:3}
.edge{fill:none;stroke:#9AA0AB;stroke-width:1.2;marker-end:url(#ar)}
.edge.bridge{stroke:var(--navy);stroke-width:2;marker-end:url(#arb)}
.edge.uni{stroke:#C3BFB4;stroke-dasharray:3 3;marker-end:url(#aru)}
.edge.dim{opacity:.07}
.edge.hot{stroke:var(--gold);stroke-width:2.6;marker-end:url(#arg)}
.elabel{font-family:var(--mono);font-size:8px;fill:#5A6270;text-anchor:middle;
pointer-events:none}
.elabel.dim{opacity:.06}
.ebg{fill:#F4F2EC}
#zoom{position:absolute;left:14px;bottom:14px;display:flex;flex-direction:column;
gap:3px;background:#fff;border:1px solid var(--rule);border-radius:4px;padding:3px}
#zoom button{width:32px;height:32px;background:#fff;border:none;border-radius:3px;
font-size:16px;cursor:pointer;color:var(--navy);font-weight:600;font-family:inherit}
#zoom button:hover{background:var(--bg)}
#hint{position:absolute;right:14px;bottom:14px;font-family:var(--mono);
font-size:9.5px;color:var(--muted);background:rgba(255,255,255,.82);
padding:5px 9px;border-radius:3px;letter-spacing:.04em}
/* ---------- inspector ---------- */
#insp{position:absolute;top:0;right:0;width:352px;max-width:100%;height:100%;
background:var(--panel);border-left:1px solid var(--rule);
box-shadow:-6px 0 24px rgba(10,31,68,.10);overflow-y:auto;padding:20px 20px 40px;
transform:translateX(100%);transition:transform .22s ease}
#insp.open{transform:translateX(0)}
#insp .close{position:absolute;top:12px;right:14px;border:none;background:none;
font-size:22px;cursor:pointer;color:var(--muted);line-height:1;padding:2px 6px}
#insp .kicker{font-family:var(--mono);font-size:9px;letter-spacing:.16em;
text-transform:uppercase;font-weight:600}
#insp h3{margin:6px 0 3px;font-size:19px;letter-spacing:-.01em}
#insp .iri{font-family:var(--mono);font-size:10.5px;color:var(--muted);
word-break:break-all;margin-bottom:14px}
#insp h4{font-family:var(--mono);font-size:9px;text-transform:uppercase;
letter-spacing:.14em;color:var(--muted);margin:20px 0 7px;font-weight:600;
border-top:1px solid var(--rule);padding-top:10px}
#insp p{font-size:12.5px;line-height:1.62;margin:0 0 8px}
.pill{display:inline-block;padding:3px 8px;border-radius:2px;font-family:var(--mono);
font-size:9px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;
margin:0 5px 5px 0;border:1px solid}
.rel{display:grid;grid-template-columns:1fr auto;gap:4px 10px;align-items:baseline;
padding:5px 0;border-bottom:1px dotted var(--rule);font-size:12px}
.rel .p{font-family:var(--mono);font-size:10.5px;color:var(--navy)}
.rel .o{color:var(--muted);font-size:11.5px;text-align:right}
.rel .flag{font-family:var(--mono);font-size:8.5px;color:var(--gold);
letter-spacing:.06em;text-transform:uppercase}
.jump{background:none;border:none;padding:0;font:inherit;color:var(--navy);
cursor:pointer;text-decoration:underline;text-underline-offset:2px}
.harvest{background:var(--bg);border-left:2px solid var(--gold);padding:9px 11px;
font-family:var(--mono);font-size:10.5px;line-height:1.55;color:#4A5160}
footer{background:var(--navy);color:rgba(255,255,255,.55);padding:6px 18px;
font-family:var(--mono);font-size:9.5px;letter-spacing:.06em;display:flex;
justify-content:space-between;flex-shrink:0}
@media(max-width:860px){
aside{position:absolute;z-index:30;height:100%;transform:translateX(-100%);
transition:transform .22s;box-shadow:4px 0 18px rgba(0,0,0,.16)}
aside.open{transform:translateX(0)}
#mtoggle{display:inline-block}
.stats{display:none}
#insp{width:100%}
#hint{display:none}
}
@media(prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
</head>
<body>
<header>
<button id="mtoggle" aria-label="Toggle filters">&#9776;</button>
<div>
<h1>PR Data Meta Model &middot; T-Box</h1>
<div class="sub">Five layers, crossed with a provenance axis: what governance asserts, what harvesters observe</div>
</div>
<div class="stats">
<div class="stat"><b id="s-cls">0</b><span>Classes</span></div>
<div class="stat"><b id="s-obj">0</b><span>Relations</span></div>
<div class="stat"><b id="s-dat">0</b><span>Attributes</span></div>
<div class="stat"><b id="s-brg">0</b><span>Seam edges</span></div>
</div>
</header>
<main>
<aside id="side">
<h2>Find a term</h2>
<input id="q" type="search" placeholder="metric, field, lineage&hellip;" autocomplete="off">
<h2>Layout</h2>
<div class="seg" role="group">
<button id="m-strata" class="on">Strata</button>
<button id="m-tax">Taxonomy</button>
<button id="m-free">Network</button>
</div>
<h2>Layers</h2>
<div id="bands"></div>
<h2>Show</h2>
<label class="opt"><input type="checkbox" id="o-uni"> Universal relations</label>
<label class="opt"><input type="checkbox" id="o-dep"> Deprecated terms</label>
<label class="opt"><input type="checkbox" id="o-lab" checked> Relation names</label>
<h2>Reading it</h2>
<p class="note">
Solid fill is <b>curated</b>: governance writes it, a pull request reviews it.
Hatched outline is <b>observed</b>: a harvester writes it nightly and no one
edits it by hand.<br><br>
The two navy edges are the seam &mdash; <code>materializedAs</code> and
<code>physicalizedIn</code>. They are the only place the two worlds meet,
and the only mapping a steward has to confirm.
</p>
</aside>
<div id="wrap">
<svg id="svg" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#9AA0AB"/></marker>
<marker id="arb" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#0A1F44"/></marker>
<marker id="aru" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#C3BFB4"/></marker>
<marker id="arg" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#B8935A"/></marker>
<pattern id="hatch" width="5" height="5" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="0" y2="5" stroke="#0A1F44" stroke-width="1.1" opacity=".16"/>
</pattern>
</defs>
<g id="vp"><g id="bL"></g><g id="eL"></g><g id="tL"></g><g id="nL"></g></g>
</svg>
<div id="zoom">
<button id="z-in" aria-label="Zoom in">+</button>
<button id="z-out" aria-label="Zoom out">&minus;</button>
<button id="z-fit" aria-label="Fit to view" style="font-size:13px">&#8635;</button>
</div>
<div id="hint">drag to pan &middot; scroll to zoom &middot; click a class</div>
</div>
<div id="insp" role="dialog" aria-label="Term details">
<button class="close" id="insp-x" aria-label="Close">&times;</button>
<div id="insp-body"></div>
</div>
</main>
<footer>
<span>Build artefact of __SRC__ &mdash; regenerate, never edit</span>
<span id="f-ver">T-Box __VERSION__</span>
</footer>
<script>
var M = __MODEL__;
/* ---------------- state ---------------- */
var NW=168, NH=46, BANDGAP=52, GUT=206;
var byId={}, off={}, mode='strata', showUni=false, showDep=false, showLab=true;
var sel=null, hot=null, view={x:0,y:0,s:1};
var svg=document.getElementById('svg'), vp=document.getElementById('vp');
var bL=document.getElementById('bL'), eL=document.getElementById('eL');
var tL=document.getElementById('tL'), nL=document.getElementById('nL');
var SEAM={'pr:materializedAs':1,'pr:physicalizedIn':1};
M.nodes.forEach(function(n){byId[n.id]=n;});
document.getElementById('s-cls').textContent=M.nodes.length;
document.getElementById('s-obj').textContent=M.edges.length+M.universal.length;
document.getElementById('s-dat').textContent=M.datatypes.length;
document.getElementById('s-brg').textContent=M.edges.filter(function(e){return SEAM[e.id];}).length;
function el(t,a){var e=document.createElementNS('http://www.w3.org/2000/svg',t);
if(a)for(var k in a)e.setAttribute(k,a[k]);return e;}
function bandOf(id){return M.bands.filter(function(b){return b.id===id;})[0];}
function visible(n){ if(off[n.band])return false; if(n.deprecated&&!showDep)return false; return true; }
/* ---------------- layout ---------------- */
function layout(){
var live=M.nodes.filter(visible);
if(mode==='tax'){ layoutTax(live); return live; }
var y=0;
M.bands.forEach(function(b){
var group=live.filter(function(n){return n.band===b.id;});
if(!group.length){b._h=0;b._y=y;return;}
var perRow=Math.max(3,Math.ceil(Math.sqrt(group.length*2.1)));
var rows=Math.ceil(group.length/perRow);
b._y=y; b._h=rows*(NH+20)+48; b._rows=rows; b._per=perRow;
group.forEach(function(n,i){
n.row=Math.floor(i/perRow);
n.x=GUT+(i%perRow)*(NW+26)+NW/2;
n.y=y+34+n.row*(NH+20)+NH/2;
});
y+=b._h+BANDGAP;
});
if(mode==='strata') relax(live);
else force(live);
return live;
}
/* barycentre relaxation on x only: connected classes drift together,
strata stay intact, which is the whole point of the drawing */
function relax(live){
var idx={}; live.forEach(function(n){idx[n.id]=n;});
var adj={}; live.forEach(function(n){adj[n.id]=[];});
M.edges.forEach(function(e){
if(idx[e.s]&&idx[e.t]&&(showDep||!e.deprecated)){
adj[e.s].push(e.t); adj[e.t].push(e.s);}
});
for(var it=0;it<70;it++){
live.forEach(function(n){
var ns=adj[n.id]; if(!ns.length)return;
var sum=0,c=0;
ns.forEach(function(m){if(idx[m]){sum+=idx[m].x;c++;}});
if(c) n.x += (sum/c - n.x)*0.22;
});
/* de-overlap inside each row */
var rows={};
live.forEach(function(n){var k=n.band+'|'+n.row;(rows[k]=rows[k]||[]).push(n);});
for(var k in rows){
var r=rows[k].sort(function(a,b){return a.x-b.x;});
for(var i=1;i<r.length;i++){
var need=(NW+22)-(r[i].x-r[i-1].x);
if(need>0){ r[i].x+=need*0.55; r[i-1].x-=need*0.45; }
}
r.forEach(function(n){ if(n.x<GUT+NW/2) n.x=GUT+NW/2; });
}
}
/* final strict pass: guarantee no overlap, preserve the order relaxation found */
var rows2={};
live.forEach(function(n){var k=n.band+'|'+n.row;(rows2[k]=rows2[k]||[]).push(n);});
for(var k2 in rows2){
var r2=rows2[k2].sort(function(a,b){return a.x-b.x;});
var cursor=Math.max(GUT+NW/2, r2[0].x);
r2.forEach(function(n){ n.x=Math.max(n.x,cursor); cursor=n.x+NW+22; });
}
}
/* plain force-directed network: no strata, for people who want the usual view */
function force(live){
var idx={}; live.forEach(function(n){idx[n.id]=n;});
var links=M.edges.filter(function(e){
return idx[e.s]&&idx[e.t]&&e.s!==e.t&&(showDep||!e.deprecated);});
var cx=live.reduce(function(a,n){return a+n.x;},0)/live.length;
var cy=live.reduce(function(a,n){return a+n.y;},0)/live.length;
for(var it=0;it<420;it++){
var k=1-it/420;
for(var i=0;i<live.length;i++){
var a=live[i];
for(var j=i+1;j<live.length;j++){
var b=live[j], dx=b.x-a.x, dy=(b.y-a.y)*1.9;
var d2=dx*dx+dy*dy||1, f=42000/d2;
if(f>9)f=9;
var d=Math.sqrt(d2);
a.x-=dx/d*f; a.y-=dy/d*f*0.5; b.x+=dx/d*f; b.y+=dy/d*f*0.5;
}
a.x+=(cx-a.x)*0.004*k; a.y+=(cy-a.y)*0.006*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-235)*0.018*k;
a.x+=dx/d*f; a.y+=dy/d*f; b.x-=dx/d*f; b.y-=dy/d*f;
});
}
}
function layoutTax(live){
var kids={}, roots=[];
live.forEach(function(n){ if(n.parent&&byId[n.parent]&&visible(byId[n.parent]))
(kids[n.parent]=kids[n.parent]||[]).push(n); else roots.push(n); });
var cursor=0;
function place(n,depth){
var ch=kids[n.id]||[];
n.x=depth*(NW+58)+NW/2+30;
if(!ch.length){ n.y=cursor*(NH+13)+NH/2+20; cursor++; }
else{ ch.forEach(function(c){place(c,depth+1);});
n.y=(ch[0].y+ch[ch.length-1].y)/2; }
}
roots.forEach(function(r){place(r,0); cursor+=0.7;});
M.bands.forEach(function(b){b._h=0;});
}
/* ---------------- render ---------------- */
function render(){
var live=layout();
var idx={}; live.forEach(function(n){idx[n.id]=n;});
bL.innerHTML=''; eL.innerHTML=''; tL.innerHTML=''; nL.innerHTML='';
if(mode==='strata'){
var maxx=Math.max.apply(null,live.map(function(n){return n.x;}).concat([600]))+NW;
M.bands.forEach(function(b){
if(!b._h)return;
bL.appendChild(el('rect',{x:0,y:b._y,width:maxx+60,height:b._h,
fill:b.color,opacity:.045,class:'bandrect'}));
bL.appendChild(el('line',{x1:0,y1:b._y,x2:maxx+60,y2:b._y,class:'bandline'}));
bL.appendChild(el('rect',{x:GUT-5,y:b._y+10,width:3,height:b._h-22,
fill:b.color,opacity:.85}));
var t=el('text',{x:GUT-20,y:b._y+26,class:'bandlabel',fill:b.color,
'text-anchor':'end'});
t.textContent=b.title; bL.appendChild(t);
var g=el('text',{x:GUT-20,y:b._y+41,class:'bandtag','text-anchor':'end'});
g.textContent=b.tag; bL.appendChild(g);
});
}
var list=M.edges.slice();
if(showUni) list=list.concat(M.universal);
list.forEach(function(e){
if(!idx[e.s]||!idx[e.t])return;
if(e.deprecated&&!showDep)return;
var a=idx[e.s], b=idx[e.t], uni=M.universal.indexOf(e)>-1;
var cls='edge'+(uni?' uni':SEAM[e.id]?' bridge':'');
var p, mx, my;
if(a===b){
p='M'+(a.x-24)+','+(a.y-NH/2)+' C'+(a.x-70)+','+(a.y-70)+' '+(a.x+70)+','+(a.y-70)+' '+(a.x+24)+','+(a.y-NH/2);
mx=a.x; my=a.y-52;
}else{
var dx=b.x-a.x, dy=b.y-a.y, k=Math.abs(dy)>4?0.38:0;
var cx=a.x+dx/2+(-dy)*k*0.14, cy=a.y+dy/2+dx*k*0.05;
p='M'+a.x+','+a.y+' Q'+cx+','+cy+' '+b.x+','+b.y;
mx=(a.x+2*cx+b.x)/4; my=(a.y+2*cy+b.y)/4;
}
var path=el('path',{d:p,class:cls}); path.dataset.e=e.id;
path.dataset.a=e.s; path.dataset.b=e.t; eL.appendChild(path);
if(showLab&&!uni){
var w=e.label.length*4.6+8;
var bg=el('rect',{x:mx-w/2,y:my-6,width:w,height:11,rx:2,class:'ebg'});
bg.dataset.el=e.id; tL.appendChild(bg);
var tx=el('text',{x:mx,y:my+2.5,class:'elabel'});
tx.dataset.el=e.id; tx.textContent=e.label; tL.appendChild(tx);
}
});
live.forEach(function(n){
var col=bandOf(n.band).color;
var g=el('g',{class:'node','tabindex':'0','role':'button',
'aria-label':n.label,transform:'translate('+(n.x-NW/2)+','+(n.y-NH/2)+')'});
g.dataset.id=n.id;
g.appendChild(el('rect',{width:NW,height:NH,rx:3,
fill:n.prov==='observed'?'#fff':col,
'fill-opacity':n.prov==='observed'?1:.13,
stroke:col,'stroke-dasharray':n.deprecated?'4 3':'none'}));
if(n.prov==='observed')
g.appendChild(el('rect',{width:NW,height:NH,rx:3,fill:'url(#hatch)',stroke:'none'}));
var lines=wrap(n.label,22);
lines.forEach(function(s,i){
var t=el('text',{x:NW/2,y:NH/2+4+(i-(lines.length-1)/2)*11.8,
fill:n.deprecated?'#9A9A9A':'#1A1A1A',
'text-decoration':n.deprecated?'line-through':'none'});
t.textContent=s; g.appendChild(t);
});
nL.appendChild(g);
});
applyHot();
}
function wrap(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,3);
}
/* ---------------- focus / highlight ---------------- */
function neighbours(id){
var s={}; s[id]=1;
M.edges.concat(M.universal).forEach(function(e){
if(e.s===id)s[e.t]=1; if(e.t===id)s[e.s]=1;});
return s;
}
function applyHot(){
var id=hot||sel;
var nodes=nL.querySelectorAll('.node');
var paths=eL.querySelectorAll('.edge');
var labs=tL.querySelectorAll('[data-el]');
if(!id){
nodes.forEach(function(n){n.classList.remove('dim','sel');});
paths.forEach(function(p){p.classList.remove('dim','hot');});
labs.forEach(function(l){l.classList.remove('dim');});
return;
}
var keep=neighbours(id), live={};
paths.forEach(function(p){
var on=(p.dataset.a===id||p.dataset.b===id);
p.classList.toggle('hot',on); p.classList.toggle('dim',!on);
if(on)live[p.dataset.e]=1;
});
labs.forEach(function(l){l.classList.toggle('dim',!live[l.dataset.el]);});
nodes.forEach(function(n){
n.classList.toggle('dim',!keep[n.dataset.id]);
n.classList.toggle('sel',n.dataset.id===sel);
});
}
/* ---------------- inspector ---------------- */
var insp=document.getElementById('insp'), ibody=document.getElementById('insp-body');
function esc(s){return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;');}
function open(id){
var n=byId[id]; if(!n)return;
sel=id; var b=bandOf(n.band), h=[];
h.push('<div class="kicker" style="color:'+b.color+'">'+esc(b.title)+'</div>');
h.push('<h3>'+esc(n.label)+'</h3>');
h.push('<div class="iri">'+esc(n.id)+'</div>');
h.push('<span class="pill" style="color:'+b.color+';border-color:'+b.color+'">'+
(n.prov==='observed'?'Observed &middot; harvested':n.prov==='curated'?'Curated &middot; asserted':'Structural')+'</span>');
if(n.curation) h.push('<span class="pill" style="color:#B8935A;border-color:#B8935A">'+esc(n.curation)+'</span>');
if(n.deprecated) h.push('<span class="pill" style="color:#8B3A3A;border-color:#8B3A3A">Deprecated</span>');
if(n.comment) h.push('<h4>What it is for</h4><p>'+esc(n.comment)+'</p>');
if(n.replacedBy) h.push('<p><b>Replaced by</b> '+link(n.replacedBy)+'</p>');
if(n.harvest) h.push('<h4>Harvested from</h4><div class="harvest">'+esc(n.harvest)+'</div>');
if(n.parent) h.push('<h4>Specialises</h4><div class="rel"><span class="p">rdfs:subClassOf</span><span class="o">'+link(n.parent)+'</span></div>');
var kids=M.nodes.filter(function(m){return m.parent===id;});
if(kids.length) h.push('<h4>Specialised by</h4>'+kids.map(function(k){
return '<div class="rel"><span class="p">'+esc(k.label)+'</span><span class="o">'+
(k.deprecated?'deprecated':bandOf(k.band).title)+'</span></div>';}).join(''));
if(n.members&&n.members.length) h.push('<h4>Allowed values</h4>'+n.members.map(function(m){
return '<div class="rel"><span class="p">'+esc(m.label)+'</span><span class="o">'+esc(m.id)+'</span></div>';}).join(''));
var out=M.edges.concat(M.universal).filter(function(e){return e.s===id&&(showDep||!e.deprecated);});
var inc=M.edges.concat(M.universal).filter(function(e){return e.t===id&&(showDep||!e.deprecated);});
if(out.length) h.push('<h4>Points to</h4>'+out.map(rel).join(''));
if(inc.length) h.push('<h4>Pointed to by</h4>'+inc.map(function(e){
return '<div class="rel"><span class="p">'+esc(e.label)+'</span><span class="o">'+link(e.s)+'</span></div>';}).join(''));
ibody.innerHTML=h.join('');
insp.classList.add('open');
bindJumps(); applyHot();
}
function rel(e){
var flags=[]; if(e.functional)flags.push('exactly one');
if(e.transitive)flags.push('transitive');
if(SEAM[e.id])flags.push('seam');
return '<div class="rel"><span class="p">'+esc(e.label)+
(flags.length?' <span class="flag">'+flags.join(' &middot; ')+'</span>':'')+
'</span><span class="o">'+link(e.t)+'</span>'+
(e.comment?'<p style="grid-column:1/-1;margin:4px 0 0;font-size:11.5px;color:#6B6B6B">'+esc(e.comment)+'</p>':'')+
'</div>';
}
function link(id){
var n=byId[id];
return n?'<button class="jump" data-j="'+id+'">'+esc(n.label)+'</button>':esc(id);
}
function bindJumps(){
ibody.querySelectorAll('.jump').forEach(function(b){
b.onclick=function(){open(b.dataset.j); focusOn(b.dataset.j);};});
}
document.getElementById('insp-x').onclick=function(){
insp.classList.remove('open'); sel=null; applyHot();};
document.addEventListener('keydown',function(e){
if(e.key==='Escape'){insp.classList.remove('open'); sel=null; applyHot();}});
/* ---------------- view ---------------- */
function apply(){vp.setAttribute('transform','translate('+view.x+','+view.y+') scale('+view.s+')');}
function fit(){
var live=M.nodes.filter(visible); if(!live.length)return;
var xs=live.map(function(n){return n.x;}), ys=live.map(function(n){return n.y;});
var pad=(mode==='strata')?GUT+30:NW;
var mnx=Math.min.apply(null,xs)-pad, mxx=Math.max.apply(null,xs)+NW;
var mny=Math.min.apply(null,ys)-90, mxy=Math.max.apply(null,ys)+70;
var r=svg.getBoundingClientRect();
view.s=Math.min(r.width/(mxx-mnx),r.height/(mxy-mny),1.25);
view.x=(r.width-(mxx-mnx)*view.s)/2-mnx*view.s;
view.y=(r.height-(mxy-mny)*view.s)/2-mny*view.s;
apply();
}
function focusOn(id){
var n=byId[id]; if(!n)return;
var r=svg.getBoundingClientRect();
view.x=r.width/2-n.x*view.s-120; view.y=r.height/2-n.y*view.s; apply();
}
document.getElementById('z-in').onclick=function(){zoom(1.22);};
document.getElementById('z-out').onclick=function(){zoom(.82);};
document.getElementById('z-fit').onclick=fit;
function zoom(f){
var r=svg.getBoundingClientRect(), cx=r.width/2, cy=r.height/2;
var ns=Math.max(.18,Math.min(view.s*f,2.6));
view.x=cx-(cx-view.x)*(ns/view.s); view.y=cy-(cy-view.y)*(ns/view.s);
view.s=ns; apply();
}
var drag=false,ds,vs,pinch=null;
svg.addEventListener('mousedown',function(e){drag=true;svg.classList.add('drag');
ds={x:e.clientX,y:e.clientY};vs={x:view.x,y:view.y};});
window.addEventListener('mousemove',function(e){if(!drag)return;
view.x=vs.x+(e.clientX-ds.x);view.y=vs.y+(e.clientY-ds.y);apply();});
window.addEventListener('mouseup',function(){drag=false;svg.classList.remove('drag');});
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(.18,Math.min(view.s*f,2.6));
view.x=mx-(mx-view.x)*(ns/view.s);view.y=my-(my-view.y)*(ns/view.s);
view.s=ns;apply();},{passive:false});
svg.addEventListener('touchstart',function(e){
if(e.touches.length===1){drag=true;ds={x:e.touches[0].clientX,y:e.touches[0].clientY};
vs={x:view.x,y: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:view.s};}},{passive:true});
svg.addEventListener('touchmove',function(e){
if(e.touches.length===1&&drag){view.x=vs.x+(e.touches[0].clientX-ds.x);
view.y=vs.y+(e.touches[0].clientY-ds.y);apply();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(.18,Math.min(pinch.s*(nd/pinch.d),2.6));
var r=svg.getBoundingClientRect(),cx=r.width/2,cy=r.height/2;
view.x=cx-(cx-view.x)*(ns/view.s);view.y=cy-(cy-view.y)*(ns/view.s);
view.s=ns;apply();e.preventDefault();}},{passive:false});
svg.addEventListener('touchend',function(){drag=false;pinch=null;});
nL.addEventListener('click',function(e){
var g=e.target.closest('.node'); if(g)open(g.dataset.id);});
nL.addEventListener('keydown',function(e){
var g=e.target.closest('.node');
if(g&&(e.key==='Enter'||e.key===' ')){e.preventDefault();open(g.dataset.id);}});
nL.addEventListener('mouseover',function(e){
var g=e.target.closest('.node'); if(g&&!sel){hot=g.dataset.id;applyHot();}});
nL.addEventListener('mouseout',function(e){
if(!e.relatedTarget||!e.relatedTarget.closest||!e.relatedTarget.closest('.node')){
if(!sel){hot=null;applyHot();}}});
/* ---------------- controls ---------------- */
var bandsBox=document.getElementById('bands');
M.bands.forEach(function(b){
var n=M.nodes.filter(function(x){return x.band===b.id;}).length;
if(!n)return;
var btn=document.createElement('button');
btn.className='chip'; btn.setAttribute('aria-pressed','true');
btn.innerHTML='<span class="swatch" style="background:'+b.color+'"></span>'+
b.title+'<span class="n">'+n+'</span>';
btn.onclick=function(){
off[b.id]=!off[b.id];
btn.classList.toggle('off',!!off[b.id]);
btn.setAttribute('aria-pressed',String(!off[b.id]));
render(); fit();};
bandsBox.appendChild(btn);
});
function setMode(m){
mode=m;
['strata','tax','free'].forEach(function(k){
document.getElementById('m-'+k).classList.toggle('on',k===m);});
render(); fit();
}
document.getElementById('m-strata').onclick=function(){setMode('strata');};
document.getElementById('m-tax').onclick=function(){setMode('tax');};
document.getElementById('m-free').onclick=function(){setMode('free');};
document.getElementById('o-uni').onchange=function(){showUni=this.checked;render();};
document.getElementById('o-dep').onchange=function(){showDep=this.checked;render();fit();};
document.getElementById('o-lab').onchange=function(){showLab=this.checked;render();};
document.getElementById('mtoggle').onclick=function(){
document.getElementById('side').classList.toggle('open');};
document.getElementById('q').addEventListener('input',function(){
var v=this.value.trim().toLowerCase();
if(!v){hot=null;applyHot();return;}
var hit=M.nodes.filter(visible).filter(function(n){
return (n.label+' '+n.id+' '+(n.comment||'')).toLowerCase().indexOf(v)>-1;});
nL.querySelectorAll('.node').forEach(function(g){
var on=hit.some(function(n){return n.id===g.dataset.id;});
g.classList.toggle('dim',!on);});
if(hit.length===1){open(hit[0].id);focusOn(hit[0].id);}
});
render(); fit();
window.addEventListener('resize',fit);
</script>
</body>
</html>
"""
def main():
src = sys.argv[1] if len(sys.argv) > 1 else "pr_datametamodel_v1_0.ttl"
out = sys.argv[2] if len(sys.argv) > 2 else "pr_metamodel_viewer_v1_0.html"
subjects, engine = read_tbox(src)
model = build(subjects)
import os
emit(model, "v1.0", os.path.basename(src), out)
print("PR META MODEL - T-Box viewer")
print(" source : %s (parsed with %s)" % (src, engine))
print(" output : %s" % out)
print(" classes %d | object properties %d | datatype properties %d | bridge annotations %d"
% (len(model["nodes"]),
len(model["edges"]) + len(model["universal"]),
len(model["datatypes"]), len(model["bridges"])))
for b in model["bands"]:
c = len([n for n in model["nodes"] if n["band"] == b["id"]])
if c:
print(" %-26s %d" % (b["title"], c))
if __name__ == "__main__":
main()