980 lines
41 KiB
Python
980 lines
41 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, 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 · 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">☰</button>
|
||
|
|
<div>
|
||
|
|
<h1>PR Data Meta Model · 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…" 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 — <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">−</button>
|
||
|
|
<button id="z-fit" aria-label="Fit to view" style="font-size:13px">↻</button>
|
||
|
|
</div>
|
||
|
|
<div id="hint">drag to pan · scroll to zoom · click a class</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div id="insp" role="dialog" aria-label="Term details">
|
||
|
|
<button class="close" id="insp-x" aria-label="Close">×</button>
|
||
|
|
<div id="insp-body"></div>
|
||
|
|
</div>
|
||
|
|
</main>
|
||
|
|
|
||
|
|
<footer>
|
||
|
|
<span>Build artefact of __SRC__ — 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,'&').replace(/</g,'<');}
|
||
|
|
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 · harvested':n.prov==='curated'?'Curated · 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(' · ')+'</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()
|