1449 lines
64 KiB
Python
1449 lines
64 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""
|
||
|
|
SODH instance viewer generator
|
||
|
|
===============================
|
||
|
|
Reads instances/sodh.ttl and emits a self-contained HTML view of the Sell Out
|
||
|
|
Data Hub. Build artefact: regenerate, never edit.
|
||
|
|
|
||
|
|
USAGE
|
||
|
|
python3 scripts/generate_sodh_viewer.py [in.ttl] [out.html]
|
||
|
|
defaults: instances/sodh.ttl -> generated/sodh_viewer.html
|
||
|
|
|
||
|
|
WHAT IT IS FOR
|
||
|
|
Making one thing obvious to a business audience: SODH is owned by Sales
|
||
|
|
Performance but it cannot exist without five other domains. The default
|
||
|
|
view is the scope, not the graph -- the point is federation, not topology.
|
||
|
|
|
||
|
|
Objects flagged TO_ARBITRATE are shown, not hidden. A declared gap is the
|
||
|
|
argument: it says which decisions are pending and who owes them.
|
||
|
|
"""
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import sys
|
||
|
|
from collections import OrderedDict
|
||
|
|
|
||
|
|
PR = "https://ontology.pernod-ricard.com/metamodel/"
|
||
|
|
|
||
|
|
DOMAIN_STYLE = OrderedDict([
|
||
|
|
("DD_06", ("#0A1F44", "Sales Performance", "owner")),
|
||
|
|
("DD_10", ("#1E4B9B", "Product & Material", "contributor")),
|
||
|
|
("DD_04", ("#1E7A5F", "Customer & Channels", "contributor")),
|
||
|
|
("DD_16", ("#8B3A3A", "Financial Performance", "contributor")),
|
||
|
|
("DD_21", ("#7A4E8B", "Data Governance", "contributor")),
|
||
|
|
("DD_05", ("#B8935A", "Sales Activation", "contributor")),
|
||
|
|
])
|
||
|
|
|
||
|
|
MULTI = {"composedOf", "usesConcept", "hasElement", "hasMetric", "measures",
|
||
|
|
"computedBy", "references", "hasGrainElement", "monitoredBy", "hasSynonym",
|
||
|
|
"hasUnit", "hasSource", "packages", "exposes"}
|
||
|
|
|
||
|
|
SUBJECT_DOMAIN = "DD_06" # the hub owner: everything else is a dependency
|
||
|
|
|
||
|
|
# Every user-facing label in one place. Hyphenated throughout, and never the
|
||
|
|
# raw class name: "Physical Relation" and "Physicalized in" told a business
|
||
|
|
# reader nothing.
|
||
|
|
LABELS = {
|
||
|
|
"DataDomain": "Domain", "SubDomain": "Sub-domain",
|
||
|
|
"BusinessObject": "Business object", "BusinessConcept": "Business concept",
|
||
|
|
"Metric": "Metric", "DataObject": "Data object", "DataElement": "Data element",
|
||
|
|
"BaseTable": "Base table", "Field": "Field",
|
||
|
|
"DataProduct": "Data product", "DataContract": "Data contract",
|
||
|
|
"DataInterface": "Data interface",
|
||
|
|
}
|
||
|
|
|
||
|
|
# Which ownership roles a card shows. The Steward section is gone: one section,
|
||
|
|
# different roles per object, so the reader always looks in the same place.
|
||
|
|
ROLES = {
|
||
|
|
"DataDomain": [("Domain owner", "owner"), ("Data-governance lead", "dgl")],
|
||
|
|
"SubDomain": [("Sub-domain owner", "subOwner"), ("Data-governance lead", "dgl_up")],
|
||
|
|
"BusinessObject": [("Steward", "steward")],
|
||
|
|
"BusinessConcept": [("Steward", "steward")],
|
||
|
|
"Metric": [("Steward", "steward")],
|
||
|
|
"DataObject": [("Product owner", "productOwner")],
|
||
|
|
"DataElement": [("Steward", "steward_inherited")],
|
||
|
|
"DataProduct": [("Product owner", "productOwner")],
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- 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 domain_of(subj, rec, subjects):
|
||
|
|
"""Owning domain, walking up belongsTo when not declared."""
|
||
|
|
d = rec.get("owningDomain")
|
||
|
|
if d:
|
||
|
|
return d.replace("ex:", "")
|
||
|
|
seen = set()
|
||
|
|
cur = rec
|
||
|
|
while cur is not None:
|
||
|
|
parent = cur.get("belongsTo") or cur.get("represents")
|
||
|
|
if not parent or parent in seen:
|
||
|
|
break
|
||
|
|
seen.add(parent)
|
||
|
|
cur = subjects.get(parent)
|
||
|
|
if cur and cur.get("owningDomain"):
|
||
|
|
return cur["owningDomain"].replace("ex:", "")
|
||
|
|
m = re.match(r"ex:[A-Z]+_(\d\d)_", subj)
|
||
|
|
return "DD_" + m.group(1) if m else "DD_06"
|
||
|
|
|
||
|
|
|
||
|
|
def build(subjects):
|
||
|
|
nodes = {}
|
||
|
|
for subj, rec in subjects.items():
|
||
|
|
t = (rec.get("type") or "").replace("pr:", "")
|
||
|
|
if not t:
|
||
|
|
continue
|
||
|
|
nodes[subj] = {
|
||
|
|
"id": subj,
|
||
|
|
"kind": t,
|
||
|
|
"label": LABELS.get(t, t),
|
||
|
|
"ident": rec.get("hasIdentifier", ""),
|
||
|
|
"name": rec.get("hasName", subj.replace("ex:", "")),
|
||
|
|
"domain": domain_of(subj, rec, subjects),
|
||
|
|
"arb": rec.get("arbitrationStatus", "") == "TO_ARBITRATE",
|
||
|
|
"status": rec.get("hasStatus", ""),
|
||
|
|
"definition": rec.get("hasBusinessDefinition", ""),
|
||
|
|
"rule": rec.get("hasBusinessRule", ""),
|
||
|
|
"formula": rec.get("hasFormula", ""),
|
||
|
|
"units": as_list(rec.get("hasUnit")),
|
||
|
|
"physical": rec.get("physicalName", ""),
|
||
|
|
"format": rec.get("hasFormat", ""),
|
||
|
|
"source": ", ".join(as_list(rec.get("hasSource"))),
|
||
|
|
"version": rec.get("hasVersion", ""),
|
||
|
|
"operatedBy": rec.get("operatedBy", ""),
|
||
|
|
"governedBy": rec.get("governedBy", ""),
|
||
|
|
"productOwner": rec.get("hasProductOwner", ""),
|
||
|
|
"packages": as_list(rec.get("packages")),
|
||
|
|
"exposes": as_list(rec.get("exposes")),
|
||
|
|
"belongsTo": rec.get("belongsTo", ""),
|
||
|
|
"about": rec.get("aboutConcept", ""),
|
||
|
|
"uses": as_list(rec.get("usesConcept")),
|
||
|
|
"represents": rec.get("represents", ""),
|
||
|
|
"measures": as_list(rec.get("measures")),
|
||
|
|
"hasMetric": as_list(rec.get("hasMetric")),
|
||
|
|
"computedBy": as_list(rec.get("computedBy")),
|
||
|
|
"hasElement": as_list(rec.get("hasElement")),
|
||
|
|
"grain": as_list(rec.get("hasGrainElement")),
|
||
|
|
"references": as_list(rec.get("references")),
|
||
|
|
"steward": as_list(rec.get("monitoredBy")) or as_list(rec.get("ownedBy")),
|
||
|
|
"dgl": rec.get("hasDGL", ""),
|
||
|
|
"owner": rec.get("hasDomainOwner", ""),
|
||
|
|
"subOwner": rec.get("hasSubDomainOwner", ""),
|
||
|
|
"activation": rec.get("hasActivationStatus", "").replace("pr:", ""),
|
||
|
|
}
|
||
|
|
|
||
|
|
# reverse index: which metric computes an element
|
||
|
|
for n in nodes.values():
|
||
|
|
for de in n["computedBy"]:
|
||
|
|
if de in nodes:
|
||
|
|
nodes[de].setdefault("computedFrom", []).append(n["id"])
|
||
|
|
for n in nodes.values():
|
||
|
|
n.setdefault("computedFrom", [])
|
||
|
|
n["route"] = ("measure" if n["computedFrom"]
|
||
|
|
else "dimensional" if n["represents"] else "")
|
||
|
|
|
||
|
|
# ---- derived physical layer -------------------------------------------
|
||
|
|
# Not asserted anywhere: these nodes are PROJECTED from physicalName on the
|
||
|
|
# data objects and elements. The real physical layer is meant to be
|
||
|
|
# harvested from Snowflake, not typed by hand, so showing it as derived is
|
||
|
|
# the honest option -- it makes the gap visible instead of pretending it is
|
||
|
|
# filled.
|
||
|
|
phys = {}
|
||
|
|
for n in list(nodes.values()):
|
||
|
|
if n["kind"] == "DataObject" and n["physical"]:
|
||
|
|
pid = "phys:" + n["physical"]
|
||
|
|
phys.setdefault(pid, {"id": pid, "kind": "BaseTable",
|
||
|
|
"label": LABELS["BaseTable"],
|
||
|
|
"name": n["physical"], "domain": n["domain"],
|
||
|
|
"derived": True, "backs": []})
|
||
|
|
phys[pid]["backs"].append(n["id"])
|
||
|
|
n["materializedAs"] = pid
|
||
|
|
for n in list(nodes.values()):
|
||
|
|
if n["kind"] == "DataElement" and n["physical"]:
|
||
|
|
owner = [d for d in nodes.values()
|
||
|
|
if d["kind"] == "DataObject" and n["id"] in d["hasElement"]]
|
||
|
|
rel = owner[0]["physical"] if owner and owner[0]["physical"] else ""
|
||
|
|
pid = "phys:" + (rel + "." if rel else "") + n["physical"]
|
||
|
|
phys.setdefault(pid, {"id": pid, "kind": "Field",
|
||
|
|
"label": LABELS["Field"], "name": n["physical"],
|
||
|
|
"domain": n["domain"], "derived": True,
|
||
|
|
"relation": "phys:" + rel if rel else "",
|
||
|
|
"backs": []})
|
||
|
|
phys[pid]["backs"].append(n["id"])
|
||
|
|
n["storedIn"] = pid
|
||
|
|
for p in phys.values():
|
||
|
|
p.setdefault("ident", ""); p.setdefault("arb", False)
|
||
|
|
p.setdefault("label", LABELS.get(p["kind"], p["kind"]))
|
||
|
|
for k in ("definition","rule","formula","physical","format","source","belongsTo",
|
||
|
|
"about","represents","status","dgl","owner","subOwner","activation",
|
||
|
|
"version","operatedBy","governedBy","productOwner","storedIn",
|
||
|
|
"materializedAs","relation","stewardFrom"):
|
||
|
|
p.setdefault(k, "")
|
||
|
|
for k in ("uses","measures","hasMetric","computedBy","hasElement","grain",
|
||
|
|
"references","steward","units","computedFrom","packages","exposes",
|
||
|
|
"steward_inherited"):
|
||
|
|
p.setdefault(k, [])
|
||
|
|
p.setdefault("route", "")
|
||
|
|
nodes[p["id"]] = p
|
||
|
|
|
||
|
|
# ---- inherited roles, resolved once -----------------------------------
|
||
|
|
# Stewardship is declared on the Business Object and inherited: a Data
|
||
|
|
# Object through represents, a Data Element through its Data Object. The
|
||
|
|
# card shows the inherited holder and says so, rather than leaving a blank
|
||
|
|
# that reads as "nobody".
|
||
|
|
for n in nodes.values():
|
||
|
|
if n["kind"] == "DataObject":
|
||
|
|
bo = nodes.get(n["represents"])
|
||
|
|
n["steward_inherited"] = bo["steward"] if bo else []
|
||
|
|
n["stewardFrom"] = n["represents"]
|
||
|
|
if n["kind"] == "DataElement":
|
||
|
|
do = [d for d in nodes.values()
|
||
|
|
if d["kind"] == "DataObject" and n["id"] in d["hasElement"]]
|
||
|
|
if do:
|
||
|
|
n["steward_inherited"] = do[0].get("steward_inherited", [])
|
||
|
|
n["stewardFrom"] = do[0]["id"]
|
||
|
|
|
||
|
|
domains = []
|
||
|
|
for code, (colour, label, role) in DOMAIN_STYLE.items():
|
||
|
|
members = [n for n in nodes.values() if n["domain"] == code]
|
||
|
|
if not members:
|
||
|
|
continue
|
||
|
|
d = nodes.get("ex:" + code, {})
|
||
|
|
domains.append({
|
||
|
|
"code": code,
|
||
|
|
"colour": colour,
|
||
|
|
"label": d.get("name") or label,
|
||
|
|
"role": role,
|
||
|
|
"dgl": nodes.get(d.get("dgl", ""), {}).get("name", "—"),
|
||
|
|
"owner": nodes.get(d.get("owner", ""), {}).get("name", "—"),
|
||
|
|
"activation": d.get("activation", ""),
|
||
|
|
"counts": {k: len([m for m in members if m["kind"] == k])
|
||
|
|
for k in ("SubDomain", "BusinessObject", "BusinessConcept",
|
||
|
|
"Metric", "DataObject", "DataElement",
|
||
|
|
"BaseTable", "Field")},
|
||
|
|
"arb": len([m for m in members if m["arb"]]),
|
||
|
|
})
|
||
|
|
|
||
|
|
return {"nodes": list(nodes.values()), "domains": domains,
|
||
|
|
"subject": SUBJECT_DOMAIN, "labels": LABELS,
|
||
|
|
"roles": ROLES,
|
||
|
|
"styles": {k: v[0] for k, v in DOMAIN_STYLE.items()}}
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- output
|
||
|
|
def main():
|
||
|
|
src = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
|
||
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "instances", "sodh.ttl")
|
||
|
|
out = sys.argv[2] if len(sys.argv) > 2 else os.path.join(
|
||
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "generated", "sodh_viewer.html")
|
||
|
|
|
||
|
|
model = build(parse(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)
|
||
|
|
|
||
|
|
print("SODH VIEWER")
|
||
|
|
print(" source : %s" % src)
|
||
|
|
print(" output : %s" % out)
|
||
|
|
tot = {}
|
||
|
|
for n in model["nodes"]:
|
||
|
|
tot[n["kind"]] = tot.get(n["kind"], 0) + 1
|
||
|
|
print(" " + " | ".join("%s %d" % (k, v) for k, v in sorted(tot.items())))
|
||
|
|
print(" domains %d | to arbitrate %d"
|
||
|
|
% (len(model["domains"]), len([n for n in model["nodes"] if n["arb"]])))
|
||
|
|
for d in model["domains"]:
|
||
|
|
c = d["counts"]
|
||
|
|
print(" %-6s %-24s BO %2d BC %2d M %2d DE %3d arb %2d %s"
|
||
|
|
% (d["code"].replace("DD_", "DD-"), d["label"], c["BusinessObject"],
|
||
|
|
c["BusinessConcept"], c["Metric"], c["DataElement"], d["arb"],
|
||
|
|
"OWNER" if d["role"] == "owner" else ""))
|
||
|
|
|
||
|
|
|
||
|
|
TEMPLATE = r"""<!DOCTYPE html>
|
||
|
|
<html lang="en">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
|
|
<title>Sell Out Data Hub · scope and dependencies</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>Sell Out Data Hub</h1>
|
||
|
|
<div class="sub">Scope, dependencies and pending arbitrations</div>
|
||
|
|
</div>
|
||
|
|
<div class="tabs" role="tablist">
|
||
|
|
<button id="t-scope" class="on">Scope</button>
|
||
|
|
<button id="t-model">Model</button>
|
||
|
|
<button id="t-graph">Graph</button>
|
||
|
|
<button id="t-elements">Elements</button>
|
||
|
|
<button id="t-arb">To arbitrate</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">×</button>
|
||
|
|
<div id="insp-body"></div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<footer>
|
||
|
|
<span>Build artefact of __SRC__ — 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; });
|
||
|
|
var DOM = M.styles;
|
||
|
|
var view = "scope", filt = {q:"", dom:"", route:""};
|
||
|
|
|
||
|
|
function esc(s){ return (s||"").replace(/&/g,"&").replace(/</g,"<"); }
|
||
|
|
function col(d){ return DOM[d] || "#6B6B6B"; }
|
||
|
|
function of(kind){ return M.nodes.filter(function(n){ return n.kind===kind; }); }
|
||
|
|
function domLabel(c){
|
||
|
|
var d = M.domains.filter(function(x){return x.code===c;})[0];
|
||
|
|
return d ? d.label : c.replace("DD_","DD-");
|
||
|
|
}
|
||
|
|
document.getElementById("f-count").textContent =
|
||
|
|
M.nodes.length + " objects \u00b7 " + M.domains.length + " domains";
|
||
|
|
|
||
|
|
/* ------------------------------------------------------------ SCOPE */
|
||
|
|
function renderScope(){
|
||
|
|
var owner = M.domains.filter(function(d){return d.role==="owner";})[0];
|
||
|
|
var others = M.domains.filter(function(d){return d.role!=="owner";});
|
||
|
|
var de = of("DataElement"), arb = M.nodes.filter(function(n){return n.arb;});
|
||
|
|
var ownDe = de.filter(function(n){return n.domain===owner.code;}).length;
|
||
|
|
|
||
|
|
var h = [];
|
||
|
|
h.push('<p class="lede">The Sell Out Data Hub belongs to Sales Performance. '
|
||
|
|
+ 'It cannot be read, or trusted, without <b>' + others.length
|
||
|
|
+ ' other data domains</b>.</p>');
|
||
|
|
h.push('<p class="sublede">Every measure in SODH is qualified by product, customer, '
|
||
|
|
+ 'outlet, currency, calendar and promotion attributes that Sales Performance does '
|
||
|
|
+ 'neither define nor own. That is what a federated model looks like in practice: '
|
||
|
|
+ 'the hub is one domain, the meaning is six.</p>');
|
||
|
|
|
||
|
|
h.push('<div class="kpis">');
|
||
|
|
h.push(kpi(M.domains.length, "Domains involved"));
|
||
|
|
h.push(kpi(of("BusinessConcept").length, "Business concepts"));
|
||
|
|
h.push(kpi(of("Metric").length, "Mother metrics"));
|
||
|
|
h.push(kpi(de.length, "Data elements"));
|
||
|
|
h.push(kpi(de.length - ownDe, "Elements owned elsewhere"));
|
||
|
|
h.push(kpi(arb.length, "Awaiting arbitration", true));
|
||
|
|
h.push('</div>');
|
||
|
|
|
||
|
|
/* contribution bar */
|
||
|
|
h.push('<h2 class="sec">Where the data elements come from</h2>');
|
||
|
|
h.push('<div class="bar">');
|
||
|
|
M.domains.forEach(function(d){
|
||
|
|
var n = d.counts.DataElement;
|
||
|
|
if(!n) return;
|
||
|
|
h.push('<div style="width:' + (n/de.length*100) + '%;background:' + d.colour
|
||
|
|
+ '" title="' + esc(d.label) + ' \u2014 ' + n + '"></div>');
|
||
|
|
});
|
||
|
|
h.push('</div><div class="legend">');
|
||
|
|
M.domains.forEach(function(d){
|
||
|
|
if(!d.counts.DataElement) return;
|
||
|
|
h.push('<span><i style="background:' + d.colour + '"></i>' + esc(d.label)
|
||
|
|
+ ' \u2014 ' + d.counts.DataElement + '</span>');
|
||
|
|
});
|
||
|
|
h.push('</div>');
|
||
|
|
|
||
|
|
h.push('<h2 class="sec">The hub</h2><div class="grid">' + card(owner) + '</div>');
|
||
|
|
h.push('<h2 class="sec">Domains it depends on</h2><div class="grid">'
|
||
|
|
+ others.map(card).join("") + '</div>');
|
||
|
|
return h.join("");
|
||
|
|
}
|
||
|
|
function kpi(v, label, warn){
|
||
|
|
return '<div class="kpi' + (warn?' warn':'') + '"><b>' + v + '</b><span>'
|
||
|
|
+ label + '</span></div>';
|
||
|
|
}
|
||
|
|
function card(d){
|
||
|
|
var c = d.counts;
|
||
|
|
var nums = [["Sub-domains",c.SubDomain],["Objects",c.BusinessObject],
|
||
|
|
["Concepts",c.BusinessConcept],["Metrics",c.Metric],
|
||
|
|
["Elements",c.DataElement]];
|
||
|
|
return '<div class="card" data-dom="' + d.code + '">'
|
||
|
|
+ '<div class="top" style="background:' + d.colour + '"></div>'
|
||
|
|
+ '<div class="body">'
|
||
|
|
+ '<div class="role" style="color:' + d.colour + '">'
|
||
|
|
+ (d.role==="owner" ? "Hub owner" : "Contributor") + ' \u00b7 '
|
||
|
|
+ d.code.replace("DD_","DD-") + '</div>'
|
||
|
|
+ '<h3>' + esc(d.label) + '</h3>'
|
||
|
|
+ '<div class="who">DGL ' + esc(d.dgl) + '</div>'
|
||
|
|
+ '<div class="nums">'
|
||
|
|
+ nums.filter(function(n){return n[1];}).map(function(n){
|
||
|
|
return '<div><b>' + n[1] + '</b>' + n[0] + '</div>'; }).join("")
|
||
|
|
+ '</div>'
|
||
|
|
+ (d.arb ? '<div class="flag">' + d.arb + ' object'
|
||
|
|
+ (d.arb>1?'s':'') + ' awaiting this domain</div>' : '')
|
||
|
|
+ '</div></div>';
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ------------------------------------------------------------ MODEL */
|
||
|
|
function renderModel(){
|
||
|
|
var h = ['<p class="lede">Ten sub-domains, fifteen business objects, '
|
||
|
|
+ 'one subject each.</p>'
|
||
|
|
+ '<p class="sublede">Every business object names the single concept it is about. '
|
||
|
|
+ 'Where a name joined two notions, the object was split \u2014 that is why '
|
||
|
|
+ 'Customer and Outlet, and Currency and Exchange Rate, now sit apart.</p>'];
|
||
|
|
M.domains.forEach(function(d){
|
||
|
|
var sds = of("SubDomain").filter(function(s){return s.domain===d.code;});
|
||
|
|
if(!sds.length) return;
|
||
|
|
h.push('<h2 class="sec" style="color:' + d.colour + '">'
|
||
|
|
+ esc(d.label) + '</h2>');
|
||
|
|
h.push('<table><thead><tr><th>Sub-domain</th><th>Business object</th>'
|
||
|
|
+ '<th>Subject concept</th><th class="hide-s">Also uses</th>'
|
||
|
|
+ '<th class="hide-s">Metrics</th></tr></thead><tbody>');
|
||
|
|
sds.forEach(function(sd){
|
||
|
|
var bos = of("BusinessObject").filter(function(b){return b.belongsTo===sd.id;});
|
||
|
|
if(!bos.length){
|
||
|
|
h.push(row([tag(sd), '<span class="mono">\u2014</span>','','','']), sd.id);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
bos.forEach(function(bo,i){
|
||
|
|
var bc = byId[bo.about];
|
||
|
|
h.push('<tr class="' + (bo.arb||sd.arb?'arb':'') + '" data-id="' + bo.id + '">'
|
||
|
|
+ '<td>' + (i===0 ? tag(sd) : '') + '</td>'
|
||
|
|
+ '<td><b>' + esc(bo.name) + '</b>' + (bo.arb?' ' + pill("to arbitrate"):'') + '</td>'
|
||
|
|
+ '<td>' + (bc ? esc(bc.name) + (bc.arb?' ' + pill("to arbitrate"):'') : '\u2014') + '</td>'
|
||
|
|
+ '<td class="hide-s mono">' + (bo.uses.length
|
||
|
|
? bo.uses.map(function(u){return byId[u]?byId[u].name:u;}).join(", ")
|
||
|
|
: "\u2014") + '</td>'
|
||
|
|
+ '<td class="hide-s mono">' + (bo.hasMetric.length || "\u2014") + '</td></tr>');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
h.push('</tbody></table>');
|
||
|
|
});
|
||
|
|
return h.join("");
|
||
|
|
}
|
||
|
|
function tag(sd){
|
||
|
|
return '<span class="dot" style="background:' + col(sd.domain) + '"></span>'
|
||
|
|
+ esc(sd.name) + (sd.arb ? ' ' + pill("new") : '');
|
||
|
|
}
|
||
|
|
function pill(t){
|
||
|
|
return '<span class="pill" style="color:#7A5B2A;border-color:#B8935A;background:#FBF3E4">'
|
||
|
|
+ t + '</span>';
|
||
|
|
}
|
||
|
|
function row(cells, id){
|
||
|
|
return '<tr data-id="' + (id||"") + '"><td>' + cells.join('</td><td>') + '</td></tr>';
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ------------------------------------------------------------ ELEMENTS */
|
||
|
|
function renderElements(){
|
||
|
|
var de = of("DataElement");
|
||
|
|
var h = ['<p class="lede">' + de.length + ' data elements, each reaching business '
|
||
|
|
+ 'meaning by exactly one route.</p>'
|
||
|
|
+ '<p class="sublede">A dimensional element represents a concept directly. '
|
||
|
|
+ 'A measure element reaches meaning through the mother metric that computes it '
|
||
|
|
+ '\u2014 which also records <em>which</em> definition it follows.</p>'];
|
||
|
|
h.push('<div class="filters">'
|
||
|
|
+ '<input id="q" type="search" placeholder="filter by name or column\u2026">'
|
||
|
|
+ '<button data-r="" class="on">All</button>'
|
||
|
|
+ '<button data-r="dimensional">Dimensional</button>'
|
||
|
|
+ '<button data-r="measure">Measure</button>'
|
||
|
|
+ M.domains.map(function(d){
|
||
|
|
return '<button data-d="' + d.code + '">' + d.code.replace("DD_","DD-")
|
||
|
|
+ '</button>'; }).join("")
|
||
|
|
+ '</div>');
|
||
|
|
h.push('<div id="tbl"></div>');
|
||
|
|
return h.join("");
|
||
|
|
}
|
||
|
|
function elementRows(){
|
||
|
|
var de = of("DataElement").filter(function(n){
|
||
|
|
if(filt.dom && n.domain !== filt.dom) return false;
|
||
|
|
if(filt.route && n.route !== filt.route) return false;
|
||
|
|
if(filt.q){
|
||
|
|
var s = (n.name + " " + n.physical + " " + n.ident).toLowerCase();
|
||
|
|
if(s.indexOf(filt.q) === -1) return false;
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
});
|
||
|
|
var h = ['<table><thead><tr><th>Element</th><th class="hide-s">Domain</th>'
|
||
|
|
+ '<th>Meaning</th><th class="hide-s">Physical column</th>'
|
||
|
|
+ '<th class="hide-s">Unit</th></tr></thead><tbody>'];
|
||
|
|
de.forEach(function(n){
|
||
|
|
var meaning = n.route === "measure"
|
||
|
|
? (byId[n.computedFrom[0]] ? byId[n.computedFrom[0]].name : "metric")
|
||
|
|
: (byId[n.represents] ? byId[n.represents].name : "\u2014");
|
||
|
|
h.push('<tr data-id="' + n.id + '"><td>' + esc(n.name) + '</td>'
|
||
|
|
+ '<td class="hide-s mono"><span class="dot" style="background:' + col(n.domain)
|
||
|
|
+ '"></span>' + n.domain.replace("DD_","DD-") + '</td>'
|
||
|
|
+ '<td>' + esc(meaning) + ' <span class="mono">'
|
||
|
|
+ (n.route==="measure" ? "via metric" : "direct") + '</span></td>'
|
||
|
|
+ '<td class="hide-s mono">' + esc(n.physical || "\u2014") + '</td>'
|
||
|
|
+ '<td class="hide-s mono">' + esc(n.unit || "\u2014") + '</td></tr>');
|
||
|
|
});
|
||
|
|
h.push('</tbody></table>');
|
||
|
|
if(!de.length) h.push('<p class="sublede">Nothing matches.</p>');
|
||
|
|
return h.join("") + '<p class="sublede" style="margin-top:12px">' + de.length
|
||
|
|
+ ' of ' + of("DataElement").length + ' shown.</p>';
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ------------------------------------------------------------ ARBITRATION */
|
||
|
|
function renderArb(){
|
||
|
|
var arb = M.nodes.filter(function(n){ return n.arb; });
|
||
|
|
var byDom = {};
|
||
|
|
arb.forEach(function(n){ (byDom[n.domain] = byDom[n.domain] || []).push(n); });
|
||
|
|
var h = ['<p class="lede">' + arb.length + ' objects are proposed, not agreed.</p>'
|
||
|
|
+ '<p class="sublede">The Data Governance Office drafted these because SODH needed '
|
||
|
|
+ 'them to make sense. The domains that own the perimeter have not ratified them. '
|
||
|
|
+ 'Nothing here can be published while the flag stands \u2014 which is the point: '
|
||
|
|
+ 'a declared gap is cheaper than an invented fact that validates.</p>'];
|
||
|
|
M.domains.forEach(function(d){
|
||
|
|
var items = byDom[d.code];
|
||
|
|
if(!items) return;
|
||
|
|
h.push('<h2 class="sec" style="color:' + d.colour + '">' + esc(d.label)
|
||
|
|
+ ' \u00b7 ' + items.length + ' to ratify \u00b7 DGL ' + esc(d.dgl) + '</h2>');
|
||
|
|
h.push('<table><thead><tr><th>Object</th><th>Type</th>'
|
||
|
|
+ '<th class="hide-s">Proposed definition or role</th></tr></thead><tbody>');
|
||
|
|
items.forEach(function(n){
|
||
|
|
h.push('<tr class="arb" data-id="' + n.id + '"><td><b>' + esc(n.name) + '</b>'
|
||
|
|
+ '<div class="mono">' + esc(n.ident) + '</div></td>'
|
||
|
|
+ '<td class="mono">' + n.kind.replace(/([A-Z])/g," $1").trim() + '</td>'
|
||
|
|
+ '<td class="hide-s">' + esc(n.definition || n.source || "\u2014") + '</td></tr>');
|
||
|
|
});
|
||
|
|
h.push('</tbody></table>');
|
||
|
|
});
|
||
|
|
return h.join("");
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
/* ============================================================ GRAPH */
|
||
|
|
var G = {mode:"strata", doms:[], sel:null, hot:null, labels:true,
|
||
|
|
show:{DataDomain:true, SubDomain:true, BusinessObject:true,
|
||
|
|
BusinessConcept:true, Metric:true, DataObject:true,
|
||
|
|
DataElement:false, DataProduct:true, DataContract:true,
|
||
|
|
DataInterface:true, BaseTable:true, Field:false},
|
||
|
|
view:{x:0,y:0,s:1}, nodes:[], edges:[]};
|
||
|
|
var NW=126, NH=32, LANEPAD=26, ROWGAP=15, BANDGAP=34, GUT=142;
|
||
|
|
|
||
|
|
/* row order inside each band -- the reading order Bastien asked for:
|
||
|
|
domains above sub-domains above business objects; concepts above metrics */
|
||
|
|
var ROW = {DataDomain:0, SubDomain:1, BusinessObject:2,
|
||
|
|
BusinessConcept:0, Metric:1,
|
||
|
|
DataObject:0, DataElement:1,
|
||
|
|
DataProduct:0, DataContract:1, DataInterface:1,
|
||
|
|
BaseTable:0, Field:1};
|
||
|
|
var LAYER = {DataDomain:0, SubDomain:0, BusinessObject:0,
|
||
|
|
BusinessConcept:1, Metric:1,
|
||
|
|
DataObject:2, DataElement:2,
|
||
|
|
DataProduct:3, DataInterface:3, DataContract:3,
|
||
|
|
BaseTable:4, Field:4};
|
||
|
|
var BANDS = [
|
||
|
|
{t:"Ownership & Categorization", g:"who is accountable"},
|
||
|
|
{t:"Business", g:"what it means"},
|
||
|
|
{t:"Logical", g:"how it is structured"},
|
||
|
|
{t:"Delivery", g:"what we promise, to whom"},
|
||
|
|
{t:"Physical", g:"derived from physical names, not yet harvested"}
|
||
|
|
];
|
||
|
|
var KINDS = [["DataDomain","Domains"],["SubDomain","Sub-domains"],
|
||
|
|
["BusinessObject","Business objects"],["BusinessConcept","Concepts"],
|
||
|
|
["Metric","Metrics"],["DataObject","Data objects"],
|
||
|
|
["DataElement","Data elements"],["DataProduct","Data products"],
|
||
|
|
["DataContract","Data contracts"],["DataInterface","Data interfaces"],
|
||
|
|
["BaseTable","Base tables"],["Field","Fields"]];
|
||
|
|
|
||
|
|
function subjectColour(dom){ return dom === M.subject ? "#1E4B9B" : "#8A6A28"; }
|
||
|
|
|
||
|
|
function buildGraph(){
|
||
|
|
var keep = {};
|
||
|
|
M.nodes.forEach(function(n){
|
||
|
|
if(LAYER[n.kind] === undefined) return;
|
||
|
|
if(!G.show[n.kind]) return;
|
||
|
|
if(G.doms.length && G.doms.indexOf(n.domain) === -1) return;
|
||
|
|
keep[n.id] = {n:n, x:0, y:0, layer:LAYER[n.kind], row:ROW[n.kind]};
|
||
|
|
});
|
||
|
|
var E = [];
|
||
|
|
function edge(a, b, label){
|
||
|
|
if(keep[a] && keep[b] && a !== b)
|
||
|
|
E.push({s:a, t:b, l:label, cross: keep[a].n.domain !== keep[b].n.domain});
|
||
|
|
}
|
||
|
|
M.nodes.forEach(function(n){
|
||
|
|
edge(n.id, n.belongsTo, "belongs to");
|
||
|
|
edge(n.id, n.about, "is about");
|
||
|
|
n.uses.forEach(function(u){ edge(n.id, u, "uses"); });
|
||
|
|
edge(n.id, n.represents, "represents");
|
||
|
|
n.hasMetric.forEach(function(m){ edge(n.id, m, "has metric"); });
|
||
|
|
n.measures.forEach(function(c){ edge(n.id, c, "measures"); });
|
||
|
|
n.computedBy.forEach(function(d){ edge(n.id, d, "computed by"); });
|
||
|
|
n.hasElement.forEach(function(d){ edge(n.id, d, "has element"); });
|
||
|
|
n.references.forEach(function(d){ edge(n.id, d, "references"); });
|
||
|
|
if(n.materializedAs) edge(n.id, n.materializedAs, "materialized as");
|
||
|
|
if(n.storedIn) edge(n.id, n.storedIn, "stored in");
|
||
|
|
if(n.operatedBy) edge(n.id, n.operatedBy, "operated by");
|
||
|
|
if(n.governedBy) edge(n.id, n.governedBy, "governed by");
|
||
|
|
n.packages.forEach(function(d){ edge(n.id, d, "packages"); });
|
||
|
|
n.exposes.forEach(function(d){ edge(n.id, d, "exposes"); });
|
||
|
|
if(n.relation) edge(n.id, n.relation, "base table");
|
||
|
|
});
|
||
|
|
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 = M.domains.map(function(d){ return d.code; })
|
||
|
|
.filter(function(c){ return G.nodes.some(function(n){ return n.n.domain === c; }); });
|
||
|
|
var laneW = {}, x = GUT;
|
||
|
|
G._lanes = [];
|
||
|
|
lanes.forEach(function(code){
|
||
|
|
var members = G.nodes.filter(function(n){ return n.n.domain === code; });
|
||
|
|
var widest = 1;
|
||
|
|
BANDS.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};
|
||
|
|
G._lanes.push({code:code, x:x, w:w});
|
||
|
|
x += w;
|
||
|
|
});
|
||
|
|
G._width = x;
|
||
|
|
|
||
|
|
var y = 0;
|
||
|
|
BANDS.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.domain === 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.name.localeCompare(q.n.name); });
|
||
|
|
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 === M.subject;
|
||
|
|
bL.appendChild(el("rect", {x:l.x, y:-6, width:l.w, height:G._height+10,
|
||
|
|
fill:isSubject ? "#1E4B9B" : "#8A6A28", 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:isSubject ? "#1E4B9B" : "#8A6A28"});
|
||
|
|
t.textContent = domLabel(l.code) + (isSubject ? " \u00b7 hub owner" : "");
|
||
|
|
bL.appendChild(t);
|
||
|
|
});
|
||
|
|
BANDS.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.t; bL.appendChild(t);
|
||
|
|
var g=el("text",{x:GUT-16, y:b._y+31, class:"bandtag", "text-anchor":"end"});
|
||
|
|
g.textContent=b.g; 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 = subjectColour(n.n.domain);
|
||
|
|
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;
|
||
|
|
g.appendChild(el("rect",{width:NW, height:NH, rx:3, fill:"#FFFFFF",
|
||
|
|
stroke:c, "stroke-dasharray": n.n.arb ? "4 3" : (n.n.derived ? "1 3" : "none")}));
|
||
|
|
var lines = wrapText(n.n.name, 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);
|
||
|
|
});
|
||
|
|
if(n.n.arb) g.appendChild(el("circle",{cx:NW-7, cy:7, r:3.5, fill:"#B8935A"}));
|
||
|
|
nL.appendChild(g);
|
||
|
|
});
|
||
|
|
applyHot();
|
||
|
|
fitGraph();
|
||
|
|
var cd = G.edges.filter(function(e){ return e.cross; }).length;
|
||
|
|
document.getElementById("g-count").textContent =
|
||
|
|
G.nodes.length+" nodes \u00b7 "+G.edges.length+" relations \u00b7 "+cd+" cross-domain";
|
||
|
|
}
|
||
|
|
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();
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderGraphShell(){
|
||
|
|
var h = ['<p class="lede">The model as a graph \u2014 every object, '
|
||
|
|
+ 'its attributes and its relations.</p>'
|
||
|
|
+ '<p class="sublede">Columns are domains: everything one domain owns sits in '
|
||
|
|
+ 'one lane. <b style="color:#1E4B9B">Blue</b> is Sales Performance, the hub '
|
||
|
|
+ 'owner; <b style="color:#8A6A28">dark gold</b> is every other domain. A gold '
|
||
|
|
+ 'edge leaves its column \u2014 that is a dependency. Dashed outlines await '
|
||
|
|
+ 'arbitration; dotted ones are derived, not yet harvested.</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="#B8935A"/></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="#0A1F44"/></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">Show</div>'
|
||
|
|
+ KINDS.map(function(k){
|
||
|
|
var n = M.nodes.filter(function(x){return x.kind===k[0];}).length;
|
||
|
|
if(!n) return "";
|
||
|
|
return '<label><input type="checkbox" data-k="' + k[0] + '"'
|
||
|
|
+ (G.show[k[0]] ? " checked" : "") + '> ' + k[1]
|
||
|
|
+ ' <span class="mono">' + n + '</span></label>';
|
||
|
|
}).join("")
|
||
|
|
+ '<div class="gsec">Options</div>'
|
||
|
|
+ '<label><input type="checkbox" id="g-lab" checked> Relation names</label>'
|
||
|
|
+ '<select id="g-dom"><option value="">All domains</option>'
|
||
|
|
+ M.domains.map(function(d){ return '<option value="'+d.code+'"'
|
||
|
|
+ (G.dom===d.code?" selected":"") + '>'+esc(d.label)+'</option>'; }).join("")
|
||
|
|
+ '</select>'
|
||
|
|
+ '<div class="mono" id="g-count"></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-k]").forEach(function(cb){
|
||
|
|
cb.onchange = function(){ G.show[cb.dataset.k] = cb.checked; renderGraph(); };
|
||
|
|
});
|
||
|
|
document.getElementById("g-lab").onchange = function(){
|
||
|
|
G.labels = this.checked; renderGraph(); };
|
||
|
|
document.querySelectorAll("[data-dm]").forEach(function(cb){
|
||
|
|
cb.onchange = function(){
|
||
|
|
var on = [];
|
||
|
|
document.querySelectorAll("[data-dm]").forEach(function(x){
|
||
|
|
if(x.checked) on.push(x.dataset.dm); });
|
||
|
|
G.doms = (on.length === M.domains.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 = [];
|
||
|
|
|
||
|
|
/* reverse indexes, built once */
|
||
|
|
var PARENT_DO = {}, SUBS_OF = {}, BOS_OF = {}, BACKS = {},
|
||
|
|
METRICS_OF = {}, REALISED_BY = {};
|
||
|
|
M.nodes.forEach(function(n){
|
||
|
|
n.hasElement.forEach(function(de){ PARENT_DO[de] = n.id; });
|
||
|
|
if(n.kind === "SubDomain" && n.belongsTo) (SUBS_OF[n.belongsTo] = SUBS_OF[n.belongsTo]||[]).push(n.id);
|
||
|
|
if(n.kind === "BusinessObject" && n.belongsTo) (BOS_OF[n.belongsTo] = BOS_OF[n.belongsTo]||[]).push(n.id);
|
||
|
|
(n.backs||[]).forEach(function(b){ (BACKS[n.id] = BACKS[n.id]||[]).push(b); });
|
||
|
|
/* a concept needs to show the metrics that measure it, not only the reverse */
|
||
|
|
n.measures.forEach(function(bc){ (METRICS_OF[bc] = METRICS_OF[bc]||[]).push(n.id); });
|
||
|
|
if(n.kind === "DataObject" && n.represents)
|
||
|
|
(REALISED_BY[n.represents] = REALISED_BY[n.represents]||[]).push(n.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); } /* other views keep the simple entry */
|
||
|
|
|
||
|
|
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(byId[HIST[HIST.length-2]].name) + '</button>');
|
||
|
|
h.push('<div class="kind" style="color:' + subjectColour(n.domain) + '">'
|
||
|
|
+ esc(n.label || n.kind) + '</div>');
|
||
|
|
h.push('<h3>' + esc(n.name) + '</h3>');
|
||
|
|
h.push('<div class="iri">' + esc(n.ident || n.id) + '</div>');
|
||
|
|
if(n.arb) h.push('<div class="note"><b>Awaiting arbitration.</b> Proposed by the '
|
||
|
|
+ 'Data Governance Office; ' + esc(domLabel(n.domain))
|
||
|
|
+ ' owns this perimeter and has not ratified it. Cannot be published (OW-007).</div>');
|
||
|
|
if(n.derived) h.push('<div class="note">Projected from the physical names the model '
|
||
|
|
+ 'carries. The physical layer is meant to be harvested from Snowflake, not typed '
|
||
|
|
+ 'by hand \u2014 this is a preview of a gap, not a filled one.</div>');
|
||
|
|
|
||
|
|
/* ---- 1. ATTRIBUTES, always first: the same block in the same place on
|
||
|
|
every card, so the eye stops looking for it ---- */
|
||
|
|
var rows = [];
|
||
|
|
function at(label, v){ if(v) rows.push([label, v]); }
|
||
|
|
at("Status", n.status);
|
||
|
|
at("Source-of-truth", n.source);
|
||
|
|
if(n.kind === "DataElement"){
|
||
|
|
at("Table field", n.physical);
|
||
|
|
at("Unit", n.units.join(", "));
|
||
|
|
at("Format", n.format);
|
||
|
|
} else {
|
||
|
|
at("Unit" + (n.units.length > 1 ? "s" : ""), n.units.join(", "));
|
||
|
|
at("Format", n.format);
|
||
|
|
if(n.kind !== "DataElement") at("Physical name", n.physical);
|
||
|
|
}
|
||
|
|
at("Activation", n.activation.replace(/([A-Z])/g," $1").trim());
|
||
|
|
at("Version", n.version);
|
||
|
|
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 means ---- */
|
||
|
|
if(n.definition) h.push('<h4>Definition</h4><p>' + esc(n.definition) + '</p>');
|
||
|
|
if(n.formula) h.push('<h4>Calculation rule</h4><p class="mono">'
|
||
|
|
+ esc(n.formula) + '</p>');
|
||
|
|
if(n.rule) h.push('<h4>' + (n.kind === "DataContract" ? "Commitment" : "Open question")
|
||
|
|
+ '</h4><div class="' + (n.kind === "DataContract" ? "" : "note") + '">'
|
||
|
|
+ esc(n.rule) + '</div>');
|
||
|
|
|
||
|
|
/* ---- 3. ownership: one section everywhere, roles vary by object ---- */
|
||
|
|
var roles = M.roles[n.kind] || [];
|
||
|
|
if(roles.length){
|
||
|
|
var out = roles.map(function(r){
|
||
|
|
var ids = [];
|
||
|
|
if(r[1] === "dgl_up"){ var dd = byId[n.belongsTo]; ids = dd && dd.dgl ? [dd.dgl] : []; }
|
||
|
|
else if(r[1] === "steward_inherited") ids = n.steward_inherited || [];
|
||
|
|
else if(r[1] === "productOwner") ids = n.productOwner ? [n.productOwner] : [];
|
||
|
|
else if(Array.isArray(n[r[1]])) ids = n[r[1]];
|
||
|
|
else if(n[r[1]]) ids = [n[r[1]]];
|
||
|
|
var names = ids.map(function(i){ return byId[i] ? esc(byId[i].name) : ""; })
|
||
|
|
.filter(Boolean);
|
||
|
|
var inherited = (r[1] === "steward_inherited" && n.stewardFrom && names.length);
|
||
|
|
return '<div class="row"><span>' + r[0] + '</span><span class="mono">'
|
||
|
|
+ (names.length ? names.join(", ") : "to be defined")
|
||
|
|
+ (inherited ? ' <em class="inh">inherited</em>' : '') + '</span></div>';
|
||
|
|
}).join("");
|
||
|
|
h.push('<h4>Ownership</h4>' + out);
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ---- 4. related objects, coarsest first: the reader walks down the
|
||
|
|
meta model rather than jumping around it ---- */
|
||
|
|
var links = [];
|
||
|
|
function link(label, ids){
|
||
|
|
ids = (ids||[]).filter(function(i){ return byId[i]; });
|
||
|
|
if(ids.length) links.push([label, ids]);
|
||
|
|
}
|
||
|
|
if(n.kind === "BusinessObject" && n.belongsTo){
|
||
|
|
var sd = byId[n.belongsTo], chain = [n.belongsTo];
|
||
|
|
if(sd && byId[sd.belongsTo]) chain.push(sd.belongsTo);
|
||
|
|
links.push(["Belongs to", chain]);
|
||
|
|
} else link("Belongs to", [n.belongsTo]);
|
||
|
|
link("Operated by", [n.operatedBy]);
|
||
|
|
link("Sub-domains", SUBS_OF[n.id]);
|
||
|
|
link("Business objects", BOS_OF[n.id]);
|
||
|
|
link("Is about", [n.about]);
|
||
|
|
link("Also uses", n.uses);
|
||
|
|
link("Metrics", (n.hasMetric || []).concat(METRICS_OF[n.id] || []));
|
||
|
|
link("Measures", n.measures);
|
||
|
|
link("Represents", [n.represents]);
|
||
|
|
link("Implements metric", n.computedFrom);
|
||
|
|
link("Governed by", [n.governedBy]);
|
||
|
|
link("Exposes", n.exposes);
|
||
|
|
link("Packages", n.packages);
|
||
|
|
link("Data objects", REALISED_BY[n.id]);
|
||
|
|
link("References", n.references);
|
||
|
|
link("Grain", n.grain);
|
||
|
|
link("Held by data object", [PARENT_DO[n.id]]);
|
||
|
|
link("Computed by", n.computedBy);
|
||
|
|
if(n.hasElement.length) links.push(["Data elements", n.hasElement]);
|
||
|
|
link("Materialized as", [n.materializedAs]);
|
||
|
|
link("Stored in", [n.storedIn]);
|
||
|
|
link("Base table", [n.relation]);
|
||
|
|
link("Provides data for", BACKS[n.id]);
|
||
|
|
|
||
|
|
links.forEach(function(l){
|
||
|
|
h.push('<h4>' + l[0] + '</h4>');
|
||
|
|
l[1].slice(0,14).forEach(function(i){
|
||
|
|
h.push('<div class="row"><button class="jump" data-j="'+i+'">'
|
||
|
|
+ esc(byId[i].name)+'</button><span class="mono">'
|
||
|
|
+ (byId[i].arb ? "to arbitrate" : esc(byId[i].label || byId[i].kind))
|
||
|
|
+ '</span></div>');
|
||
|
|
});
|
||
|
|
if(l[1].length>14) h.push('<div class="row"><span class="mono">\u2026 and '
|
||
|
|
+ (l[1].length-14)+' more</span><span></span></div>');
|
||
|
|
});
|
||
|
|
|
||
|
|
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 === "elements" ? renderElements()
|
||
|
|
: renderArb();
|
||
|
|
if(view === "graph"){ wireGraph(); }
|
||
|
|
if(view === "elements"){
|
||
|
|
document.getElementById("tbl").innerHTML = elementRows();
|
||
|
|
document.getElementById("q").oninput = function(){
|
||
|
|
filt.q = this.value.trim().toLowerCase();
|
||
|
|
document.getElementById("tbl").innerHTML = elementRows();
|
||
|
|
bindRows();
|
||
|
|
};
|
||
|
|
host.querySelectorAll(".filters button").forEach(function(b){
|
||
|
|
b.onclick = function(){
|
||
|
|
if(b.dataset.r !== undefined){
|
||
|
|
filt.route = b.dataset.r;
|
||
|
|
host.querySelectorAll("[data-r]").forEach(function(x){
|
||
|
|
x.classList.toggle("on", x === b); });
|
||
|
|
} else {
|
||
|
|
filt.dom = (filt.dom === b.dataset.d) ? "" : b.dataset.d;
|
||
|
|
host.querySelectorAll("[data-d]").forEach(function(x){
|
||
|
|
x.classList.toggle("on", x.dataset.d === filt.dom); });
|
||
|
|
}
|
||
|
|
document.getElementById("tbl").innerHTML = elementRows();
|
||
|
|
bindRows();
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
bindRows();
|
||
|
|
host.querySelectorAll(".card").forEach(function(c){
|
||
|
|
c.onclick = function(){
|
||
|
|
filt.dom = c.dataset.dom; filt.route = ""; filt.q = "";
|
||
|
|
setView("elements");
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
function bindRows(){
|
||
|
|
host.querySelectorAll("tr[data-id]").forEach(function(tr){
|
||
|
|
if(!tr.dataset.id) return;
|
||
|
|
tr.onclick = function(){ open(tr.dataset.id); };
|
||
|
|
});
|
||
|
|
}
|
||
|
|
function setView(v){
|
||
|
|
view = v;
|
||
|
|
["scope","model","graph","elements","arb"].forEach(function(k){
|
||
|
|
document.getElementById("t-" + k).classList.toggle("on", k === v); });
|
||
|
|
render();
|
||
|
|
document.querySelector("main").scrollTop = 0;
|
||
|
|
}
|
||
|
|
document.getElementById("t-scope").onclick = function(){ setView("scope"); };
|
||
|
|
document.getElementById("t-model").onclick = function(){ setView("model"); };
|
||
|
|
document.getElementById("t-graph").onclick = function(){ setView("graph"); };
|
||
|
|
document.getElementById("t-elements").onclick = function(){
|
||
|
|
filt.dom = ""; filt.route = ""; filt.q = ""; setView("elements"); };
|
||
|
|
document.getElementById("t-arb").onclick = function(){ setView("arb"); };
|
||
|
|
|
||
|
|
render();
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|
||
|
|
"""
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|