Files
data-meta-model/generated/pr_metamodel_viewer.html
T

637 lines
75 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PR Data Meta Model &middot; T-Box v1.0</title>
<style>
:root{
--navy:#0A1F44; --gold:#B8935A; --ink:#1A1A1A;
--bg:#F7F6F2; --panel:#FFFFFF; --rule:#E4E2DA; --muted:#6B6B6B;
--c-roots:#0A1F44; --c-ownership:#B8935A; --c-business:#8B3A3A;
--c-logical:#7A4E8B; --c-physical:#1E4B9B; --c-consumption:#1E7A5F;
--c-context:#6B6B6B;
--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{height:100%;margin:0}
body{font-family:var(--sans);background:var(--bg);color:var(--ink);
display:flex;flex-direction:column;overflow:hidden}
:focus-visible{outline:2px solid var(--gold);outline-offset:2px}
/* ---------- chrome ---------- */
header{background:var(--navy);color:#fff;padding:10px 18px;display:flex;
align-items:center;gap:16px;flex-shrink:0;box-shadow:0 2px 6px rgba(0,0,0,.15)}
header h1{margin:0;font-family:var(--mono);font-size:13px;font-weight:600;
letter-spacing:.16em;text-transform:uppercase}
header .sub{font-size:11px;color:rgba(255,255,255,.62);margin-top:3px}
.stats{margin-left:auto;display:flex;gap:20px}
.stat{text-align:right;line-height:1.15}
.stat b{display:block;font-family:var(--mono);font-size:16px;color:var(--gold)}
.stat span{font-size:9px;text-transform:uppercase;letter-spacing:.09em;
color:rgba(255,255,255,.55)}
#mtoggle{display:none;background:transparent;color:#fff;
border:1px solid rgba(255,255,255,.4);padding:5px 10px;border-radius:3px;
font-size:11px;font-weight:600;cursor:pointer;font-family:inherit}
main{flex:1;display:flex;min-height:0;position:relative}
/* ---------- sidebar ---------- */
aside{width:255px;background:var(--panel);border-right:1px solid var(--rule);
overflow-y:auto;padding:16px;flex-shrink:0}
aside h2{font-family:var(--mono);font-size:9.5px;text-transform:uppercase;
letter-spacing:.14em;color:var(--muted);margin:22px 0 9px;font-weight:600}
aside h2:first-child{margin-top:0}
#q{width:100%;padding:8px 10px;border:1px solid var(--rule);border-radius:3px;
font-family:var(--mono);font-size:12px;background:var(--bg)}
#q:focus{border-color:var(--navy);outline:none;background:#fff}
.chip{display:flex;align-items:center;gap:9px;padding:5px 0;font-size:12.5px;
cursor:pointer;user-select:none;border:none;background:none;width:100%;
text-align:left;font-family:inherit;color:inherit}
.chip .swatch{width:11px;height:11px;flex-shrink:0;border-radius:2px}
.chip .n{margin-left:auto;font-family:var(--mono);font-size:10px;color:var(--muted)}
.chip.off{opacity:.34}
.seg{display:flex;border:1px solid var(--rule);border-radius:3px;overflow:hidden}
.seg button{flex:1;padding:7px 4px;background:#fff;border:none;cursor:pointer;
font-family:var(--mono);font-size:9.5px;letter-spacing:.07em;
text-transform:uppercase;color:var(--muted)}
.seg button+button{border-left:1px solid var(--rule)}
.seg button.on{background:var(--navy);color:#fff}
.opt{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:12px;cursor:pointer}
.opt input{accent-color:var(--navy);width:15px;height:15px;cursor:pointer}
.note{font-size:11px;line-height:1.55;color:var(--muted)}
.note code{font-family:var(--mono);font-size:10.5px;background:var(--bg);padding:1px 4px}
/* ---------- canvas ---------- */
#wrap{flex:1;min-width:0;position:relative;overflow:hidden;touch-action:none;
background:
linear-gradient(90deg,rgba(10,31,68,.035) 1px,transparent 1px) 0 0/28px 28px,
linear-gradient(180deg,#FCFBF7,#EFEDE3)}
svg{display:block;width:100%;height:100%;cursor:grab}
svg.drag{cursor:grabbing}
.bandrect{opacity:.5}
.bandline{stroke:var(--rule);stroke-width:1}
.bandlabel{font-family:var(--mono);font-size:10px;letter-spacing:.15em;
text-transform:uppercase;font-weight:600}
.bandtag{font-family:var(--sans);font-size:9.5px;fill:var(--muted);font-style:italic}
.node{cursor:pointer}
.node rect{stroke-width:1.5}
.node text{font-size:10px;pointer-events:none;text-anchor:middle;font-weight:500}
.node.dim{opacity:.13}
.node.sel rect{stroke-width:3}
.edge{fill:none;stroke:#9AA0AB;stroke-width:1.2;marker-end:url(#ar)}
.edge.bridge{stroke:var(--navy);stroke-width:2;marker-end:url(#arb)}
.edge.uni{stroke:#C3BFB4;stroke-dasharray:3 3;marker-end:url(#aru)}
.edge.dim{opacity:.07}
.edge.hot{stroke:var(--gold);stroke-width:2.6;marker-end:url(#arg)}
.elabel{font-family:var(--mono);font-size:8px;fill:#5A6270;text-anchor:middle;
pointer-events:none}
.elabel.dim{opacity:.06}
.ebg{fill:#F4F2EC}
#zoom{position:absolute;left:14px;bottom:14px;display:flex;flex-direction:column;
gap:3px;background:#fff;border:1px solid var(--rule);border-radius:4px;padding:3px}
#zoom button{width:32px;height:32px;background:#fff;border:none;border-radius:3px;
font-size:16px;cursor:pointer;color:var(--navy);font-weight:600;font-family:inherit}
#zoom button:hover{background:var(--bg)}
#hint{position:absolute;right:14px;bottom:14px;font-family:var(--mono);
font-size:9.5px;color:var(--muted);background:rgba(255,255,255,.82);
padding:5px 9px;border-radius:3px;letter-spacing:.04em}
/* ---------- inspector ---------- */
#insp{position:absolute;top:0;right:0;width:352px;max-width:100%;height:100%;
background:var(--panel);border-left:1px solid var(--rule);
box-shadow:-6px 0 24px rgba(10,31,68,.10);overflow-y:auto;padding:20px 20px 40px;
transform:translateX(100%);transition:transform .22s ease}
#insp.open{transform:translateX(0)}
#insp .close{position:absolute;top:12px;right:14px;border:none;background:none;
font-size:22px;cursor:pointer;color:var(--muted);line-height:1;padding:2px 6px}
#insp .kicker{font-family:var(--mono);font-size:9px;letter-spacing:.16em;
text-transform:uppercase;font-weight:600}
#insp h3{margin:6px 0 3px;font-size:19px;letter-spacing:-.01em}
#insp .iri{font-family:var(--mono);font-size:10.5px;color:var(--muted);
word-break:break-all;margin-bottom:14px}
#insp h4{font-family:var(--mono);font-size:9px;text-transform:uppercase;
letter-spacing:.14em;color:var(--muted);margin:20px 0 7px;font-weight:600;
border-top:1px solid var(--rule);padding-top:10px}
#insp p{font-size:12.5px;line-height:1.62;margin:0 0 8px}
.pill{display:inline-block;padding:3px 8px;border-radius:2px;font-family:var(--mono);
font-size:9px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;
margin:0 5px 5px 0;border:1px solid}
.rel{display:grid;grid-template-columns:1fr auto;gap:4px 10px;align-items:baseline;
padding:5px 0;border-bottom:1px dotted var(--rule);font-size:12px}
.rel .p{font-family:var(--mono);font-size:10.5px;color:var(--navy)}
.rel .o{color:var(--muted);font-size:11.5px;text-align:right}
.rel .flag{font-family:var(--mono);font-size:8.5px;color:var(--gold);
letter-spacing:.06em;text-transform:uppercase}
.jump{background:none;border:none;padding:0;font:inherit;color:var(--navy);
cursor:pointer;text-decoration:underline;text-underline-offset:2px}
.harvest{background:var(--bg);border-left:2px solid var(--gold);padding:9px 11px;
font-family:var(--mono);font-size:10.5px;line-height:1.55;color:#4A5160}
footer{background:var(--navy);color:rgba(255,255,255,.55);padding:6px 18px;
font-family:var(--mono);font-size:9.5px;letter-spacing:.06em;display:flex;
justify-content:space-between;flex-shrink:0}
@media(max-width:860px){
aside{position:absolute;z-index:30;height:100%;transform:translateX(-100%);
transition:transform .22s;box-shadow:4px 0 18px rgba(0,0,0,.16)}
aside.open{transform:translateX(0)}
#mtoggle{display:inline-block}
.stats{display:none}
#insp{width:100%}
#hint{display:none}
}
@media(prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
</head>
<body>
<header>
<button id="mtoggle" aria-label="Toggle filters">&#9776;</button>
<div>
<h1>PR Data Meta Model &middot; T-Box</h1>
<div class="sub">Five layers, crossed with a provenance axis: what governance asserts, what harvesters observe</div>
</div>
<div class="stats">
<div class="stat"><b id="s-cls">0</b><span>Classes</span></div>
<div class="stat"><b id="s-obj">0</b><span>Relations</span></div>
<div class="stat"><b id="s-dat">0</b><span>Attributes</span></div>
<div class="stat"><b id="s-brg">0</b><span>Seam edges</span></div>
</div>
</header>
<main>
<aside id="side">
<h2>Find a term</h2>
<input id="q" type="search" placeholder="metric, field, lineage&hellip;" autocomplete="off">
<h2>Layout</h2>
<div class="seg" role="group">
<button id="m-strata" class="on">Strata</button>
<button id="m-tax">Taxonomy</button>
<button id="m-free">Network</button>
</div>
<h2>Layers</h2>
<div id="bands"></div>
<h2>Show</h2>
<label class="opt"><input type="checkbox" id="o-uni"> Universal relations</label>
<label class="opt"><input type="checkbox" id="o-dep"> Deprecated terms</label>
<label class="opt"><input type="checkbox" id="o-lab" checked> Relation names</label>
<h2>Reading it</h2>
<p class="note">
Solid fill is <b>curated</b>: governance writes it, a pull request reviews it.
Hatched outline is <b>observed</b>: a harvester writes it nightly and no one
edits it by hand.<br><br>
The two navy edges are the seam &mdash; <code>materializedAs</code> and
<code>physicalizedIn</code>. They are the only place the two worlds meet,
and the only mapping a steward has to confirm.
</p>
</aside>
<div id="wrap">
<svg id="svg" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#9AA0AB"/></marker>
<marker id="arb" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#0A1F44"/></marker>
<marker id="aru" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#C3BFB4"/></marker>
<marker id="arg" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#B8935A"/></marker>
<pattern id="hatch" width="5" height="5" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="0" y2="5" stroke="#0A1F44" stroke-width="1.1" opacity=".16"/>
</pattern>
</defs>
<g id="vp"><g id="bL"></g><g id="eL"></g><g id="tL"></g><g id="nL"></g></g>
</svg>
<div id="zoom">
<button id="z-in" aria-label="Zoom in">+</button>
<button id="z-out" aria-label="Zoom out">&minus;</button>
<button id="z-fit" aria-label="Fit to view" style="font-size:13px">&#8635;</button>
</div>
<div id="hint">drag to pan &middot; scroll to zoom &middot; click a class</div>
</div>
<div id="insp" role="dialog" aria-label="Term details">
<button class="close" id="insp-x" aria-label="Close">&times;</button>
<div id="insp-body"></div>
</div>
</main>
<footer>
<span>Build artefact of pr_metamodel.ttl &mdash; regenerate, never edit</span>
<span id="f-ver">T-Box v1.0</span>
</footer>
<script>
var M = {"bands":[{"id":"roots","title":"Model roots","tag":"How a term got here","color":"#0A1F44"},{"id":"ownership","title":"Ownership & Categorization","tag":"Who is accountable","color":"#B8935A"},{"id":"business","title":"Business","tag":"What the business means","color":"#8B3A3A"},{"id":"logical","title":"Logical","tag":"How the data is structured","color":"#7A4E8B"},{"id":"delivery","title":"Delivery","tag":"What we promise, to whom","color":"#B8935A"},{"id":"physical","title":"Physical","tag":"What actually runs","color":"#1E4B9B"},{"id":"consumption","title":"Consumption","tag":"What people open in the morning","color":"#1E7A5F"},{"id":"context","title":"Actors & vocabularies","tag":"Outside the object stack","color":"#6B6B6B"}],"nodes":[{"id":"pr:DataDomainOwner","label":"Data Domain Owner","band":"context","prov":"neutral","parent":"pr:Actor","comment":"Accountable for a Data Domain. Exactly one per domain.","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:BaseTable","label":"Base Table","band":"physical","prov":"observed","parent":"pr:PhysicalRelation","comment":"","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:CuratedObject","label":"Curated Object","band":"roots","prov":"curated","parent":"pr:MetaModelObject","comment":"Asserted by governance, edited through pull request on the domain TTL file, subject to the full ownership and lifecycle shape (identifier, name, owner, owning Data Domain, status, version).","harvest":"","curation":"ASSERTED","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:Table","label":"Table (deprecated)","band":"physical","prov":"observed","parent":"pr:PhysicalRelation","comment":"v0.9 term. Kept so that existing assertions still classify. Use BaseTable.","harvest":"","curation":"","deprecated":true,"replacedBy":"pr:BaseTable","members":[]},{"id":"pr:DataInterface","label":"Data Interface","band":"delivery","prov":"curated","parent":"pr:DeliveryLayerObject","comment":"The channel through which a consumer actually gets the data: a Snowflake secure share or listing, a consumption schema with its grants, a file extract to a panel partner, an API or reverse-ETL feed. Not a Data Object (that is structure) and not a BI Data Source (that is one consumer among others). Verifiable: a grant, a share or an endpoint either exists or it does not. The output port in data-mesh terms.","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:ForeignKeyConstraint","label":"Foreign Key Constraint","band":"physical","prov":"observed","parent":"pr:KeyConstraint","comment":"","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:DataContract","label":"Data Contract","band":"delivery","prov":"curated","parent":"pr:DeliveryLayerObject","comment":"The service commitment attached to a Data Product: schema, freshness, quality thresholds, support, breaking-change notice period. Candidate alignment: ODCS (Bitol).","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:Transformation","label":"Transformation","band":"physical","prov":"observed","parent":"pr:PhysicalLayerObject","comment":"The code that turns inputs into outputs: a dbt model, a Snowflake task, an ADF pipeline. Subclass of prov:Activity so that lineage is expressed in a standard vocabulary rather than a private one.","harvest":"dbt manifest.json, Snowflake TASKS, Azure Data Factory pipelines","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:PhysicalLayerObject","label":"Physical Layer Object","band":"physical","prov":"observed","parent":"pr:ObservedObject","comment":"","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:SubDomainOwner","label":"Sub-Domain Owner","band":"context","prov":"neutral","parent":"pr:Actor","comment":"Accountable for a Sub-Domain. Exactly one per sub-domain.","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:Metric","label":"Metric","band":"business","prov":"curated","parent":"pr:BusinessLayerObject","comment":"A quantifiable measurement WITH a formula, a unit and a grain. Under BR-013 a Metric carries the harmonised CALCULATION RULE; variants that differ only by analysis context are Data Elements linked back via computedBy.","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:System","label":"System","band":"physical","prov":"observed","parent":"pr:PhysicalLayerObject","comment":"A real system: Snowflake, MDM Product, MDM Customer, a panel provider feed, Tableau Server, Looker. Making this a node rather than a string is what makes 'what depends on MDM Product?' answerable.","harvest":"Manually seeded once, then referenced. Replaces the free-text hasSourceSystem.","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:Database","label":"Database","band":"physical","prov":"observed","parent":"pr:PhysicalLayerObject","comment":"","harvest":"SNOWFLAKE.INFORMATION_SCHEMA.DATABASES","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:Schema","label":"Schema","band":"physical","prov":"observed","parent":"pr:PhysicalLayerObject","comment":"","harvest":"SNOWFLAKE.INFORMATION_SCHEMA.SCHEMATA","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:PhysicalRelation","label":"Physical Relation","band":"physical","prov":"observed","parent":"pr:PhysicalLayerObject","comment":"Abstract parent of everything with columns. Specialised because impact traversal differs by kind: a View propagates impact to whatever it reads.","harvest":"SNOWFLAKE.INFORMATION_SCHEMA.TABLES","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:PrimaryKey","label":"Primary Key","band":"physical","prov":"observed","parent":"pr:KeyConstraint","comment":"","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:DataAsset","label":"Data Asset (deprecated)","band":"logical","prov":"curated","parent":"pr:LogicalLayerObject","comment":"v1.0 term, defined as 'directly consumable by a business user' -- a judgement, not a structural test, which is why it collected zero instances in a year. Use DataInterface.","harvest":"","curation":"","deprecated":true,"replacedBy":"pr:DataInterface","members":[]},{"id":"pr:BIField","label":"BI Field","band":"consumption","prov":"observed","parent":"pr:ConsumptionLayerObject","comment":"A field as a business user sees it. realizesMetric is what turns a certified Metric from a definition on paper into something measurable against the real BI estate.","harvest":"Tableau calculated fields ; LookML measures and dimensions","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:DataElement","label":"Data Element","band":"logical","prov":"curated","parent":"pr:LogicalLayerObject","comment":"The finest curated unit. Its mapping to a physical Field is the ONLY place where the curated graph and the observed graph meet: proposed automatically by name and type matching, confirmed by a Data Steward.","harvest":"","curation":"PROPOSED_CONFIRMED","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:BusinessConcept","label":"Business Concept","band":"business","prov":"curated","parent":"pr:BusinessLayerObject","comment":"A shared business notion WITHOUT a formula. The presence of a formula is the sole discriminator against Metric (rule BR-003).","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:Environment","label":"Environment","band":"context","prov":"neutral","parent":"","comment":"Same logical object, several physical incarnations. Kept as a vocabulary rather than folded into the name so that DEV drift never pollutes PROD lineage.","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[{"id":"pr:Development","label":"Development"},{"id":"pr:UserAcceptance","label":"User Acceptance"},{"id":"pr:Production","label":"Production"}]},{"id":"pr:DeliveryLayerObject","label":"Delivery Layer Object","band":"delivery","prov":"curated","parent":"pr:CuratedObject","comment":"What we promise, to whom, until when. A different question from how the data is structured, hence a layer of its own: version, owner, changelog, support channel, consumers to warn.","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:DataProduct","label":"Data Product","band":"delivery","prov":"curated","parent":"pr:DeliveryLayerObject","comment":"The unit of delivery: what carries a version number, an owner, a changelog, a support channel and a list of consumers to warn. It has no single technical counterpart, and should not: physically it often lands on a Snowflake schema or a dbt project, but that is not what defines it. Operated by exactly one Sub-Domain -- it is a consumable, and there must be no ambiguity about who runs it. Candidate alignment: dcat:Dataset / DPROD.","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:ExternalTable","label":"External Table","band":"physical","prov":"observed","parent":"pr:PhysicalRelation","comment":"Backed by files outside the warehouse. Marks the boundary where lineage leaves Snowflake.","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:View","label":"View","band":"physical","prov":"observed","parent":"pr:PhysicalRelation","comment":"Depends on other relations through dependsOn, so impact is transitive.","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:OwnershipLayerObject","label":"Ownership & Categorization Layer Object","band":"ownership","prov":"curated","parent":"pr:CuratedObject","comment":"","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:BIDataSource","label":"BI Data Source","band":"consumption","prov":"observed","parent":"pr:ConsumptionLayerObject","comment":"The semantic surface a report is built on. This is where BI meets the warehouse.","harvest":"Tableau Published Datasource ; Looker Explore","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:KPI","label":"Key Performance Indicator","band":"business","prov":"curated","parent":"pr:Metric","comment":"An elevated Metric. Use this class rather than the deprecated isKPI flag.","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:BIWorkspace","label":"BI Workspace","band":"consumption","prov":"observed","parent":"pr:ConsumptionLayerObject","comment":"","harvest":"Tableau Metadata API (Project/Site) ; Looker API (Model)","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:KeyConstraint","label":"Key Constraint","band":"physical","prov":"observed","parent":"pr:PhysicalLayerObject","comment":"Gives foreign keys a home. Rule OW-006 says an FK is not a Data Element - true, but it still needs somewhere to live, and it lets the asserted DataObject-references-DataObject edge be checked against reality.","harvest":"SNOWFLAKE.INFORMATION_SCHEMA.TABLE_CONSTRAINTS + KEY_COLUMN_USAGE","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:BusinessObject","label":"Business Object","band":"ownership","prov":"curated","parent":"pr:OwnershipLayerObject","comment":"The single anchor of accountability, which is why it stays in the Ownership layer: it is the one object where a steward is declared, and Data Objects and Data Elements inherit from it. It carries NO business definition of its own -- its meaning comes from its aboutConcept. What it does carry is a perimeter: the subject it is about, and the notions needed to read it.","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:DataGovernanceLead","label":"Data Governance Lead","band":"context","prov":"neutral","parent":"pr:Actor","comment":"Runs governance for a Data Domain. The acronym belongs in hasAcronym, not in the label (rule NR-005).","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:Field","label":"Field","band":"physical","prov":"observed","parent":"pr:PhysicalLayerObject","comment":"A physical column. The anchor of impact analysis: everything upstream reaches a Field, everything downstream starts from one.","harvest":"SNOWFLAKE.INFORMATION_SCHEMA.COLUMNS","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:MetaModelObject","label":"Meta Model Object","band":"roots","prov":"neutral","parent":"","comment":"Anything the meta model describes. Actors are deliberately NOT Meta Model Objects.","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:DataSteward","label":"Data Steward","band":"context","prov":"neutral","parent":"pr:Actor","comment":"Monitors a Business Object. Declared there and nowhere else: Data Objects and Data Elements inherit their steward, so that no column can ever have two answers to 'who watches this'.","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:ObservedObject","label":"Observed Object","band":"roots","prov":"observed","parent":"pr:MetaModelObject","comment":"Harvested from a running system and rebuilt on every run. Never hand-edited. Carries sourceIdentifier, fullyQualifiedName and harvestedOn instead of curated ownership: attribution is INFERRED through the Data Element it is mapped to, not asserted on the node.","harvest":"","curation":"HARVESTED","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:DataDomain","label":"Data Domain","band":"ownership","prov":"curated","parent":"pr:OwnershipLayerObject","comment":"","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:MaterializedView","label":"Materialized View","band":"physical","prov":"observed","parent":"pr:PhysicalRelation","comment":"","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:BIReport","label":"BI Report","band":"consumption","prov":"observed","parent":"pr:ConsumptionLayerObject","comment":"What a Data Governance Lead actually cares about in an impact analysis: not that a column changed, but which reports break and who to warn.","harvest":"Tableau Workbook/Sheet ; Looker Look/Dashboard","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:ConsumptionLayerObject","label":"Consumption Layer Object","band":"consumption","prov":"observed","parent":"pr:ObservedObject","comment":"The BI estate. Separate from Physical because BI assets have their own lifecycle and their own owner: a dashboard belongs to its author, not to the Data Domain that owns the underlying data.","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:LogicalLayerObject","label":"Logical Layer Object","band":"logical","prov":"curated","parent":"pr:CuratedObject","comment":"How the data is structured.","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:ActivationStatus","label":"Activation Status","band":"context","prov":"neutral","parent":"","comment":"","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[{"id":"pr:FullActivation","label":"Full Activation"},{"id":"pr:LightActivation","label":"Light Activation"},{"id":"pr:NotActivated","label":"Not Activated"}]},{"id":"pr:SystemType","label":"System Type","band":"context","prov":"neutral","parent":"","comment":"","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[{"id":"pr:ExternalProvider","label":"External Provider"},{"id":"pr:DataPlatform","label":"Data Platform"},{"id":"pr:MasterDataSystem","label":"Master Data System"},{"id":"pr:SourceApplication","label":"Source Application"},{"id":"pr:BITool","label":"BI Tool"}]},{"id":"pr:SubDomain","label":"Sub-Domain","band":"ownership","prov":"curated","parent":"pr:OwnershipLayerObject","comment":"","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:BusinessLayerObject","label":"Business Layer Object","band":"business","prov":"curated","parent":"pr:CuratedObject","comment":"","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:Actor","label":"Actor","band":"context","prov":"neutral","parent":"","comment":"A person or a team. Deliberately NOT a MetaModelObject: people are not subject to ownership and lifecycle rules.","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:DataObject","label":"Data Object","band":"logical","prov":"curated","parent":"pr:LogicalLayerObject","comment":"The pivot: represents exactly one Business Object, is composed of Data Elements, and is materialized as one or more Physical Relations. The single junction between governance and technology.","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]},{"id":"pr:DataProductOwner","label":"Data Product Owner","band":"context","prov":"neutral","parent":"pr:Actor","comment":"Runs a Data Product day to day: releases, changelog, support, consumer communication. Belongs to the Sub-Domain that operates the product.","harvest":"","curation":"","deprecated":false,"replacedBy":"","members":[]}],"edges":[{"id":"pr:packages","label":"packages","s":"pr:DataProduct","t":"pr:DataObject","comment":"The scope of the product. Through these Data Objects and their represents, the Business Objects a product makes available are derivable -- which is why a product needs no direct link to a Business Object.","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:computedBy","label":"computed by","s":"pr:Metric","t":"pr:DataElement","comment":"BR-013. Links a mother Metric to the granular Data Elements that implement it in a given analysis context (period, promo status, scope, unit, currency).","harvest":"","curation":"","inverseOf":"","subPropertyOf":"prov:wasDerivedFrom","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasDGL","label":"has DGL","s":"pr:DataDomain","t":"pr:DataGovernanceLead","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:derivedFrom","label":"derived from","s":"pr:Field","t":"pr:Field","comment":"Column-to-column lineage. The single highest-value edge in the model, and largely free: Snowflake already computes it.","harvest":"SNOWFLAKE.ACCOUNT_USAGE.ACCESS_HISTORY (column lineage) ; dbt manifest","curation":"","inverseOf":"","subPropertyOf":"prov:wasDerivedFrom","functional":false,"transitive":true,"deprecated":false,"replacedBy":""},{"id":"pr:references","label":"references","s":"pr:DataObject","t":"pr:DataObject","comment":"Foreign-key reference between Data Objects. NOT ownership. Now checkable against the harvested ForeignKeyConstraint objects.","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasMetric","label":"has metric","s":"pr:BusinessObject","t":"pr:Metric","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:inSchema","label":"in schema","s":"pr:PhysicalRelation","t":"pr:Schema","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasElement","label":"has element","s":"pr:DataObject","t":"pr:DataElement","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:biDerivedFrom","label":"BI derived from","s":"pr:BIField","t":"pr:Field","comment":"Closes the last gap in the impact chain: from a dashboard field back to the warehouse column.","harvest":"","curation":"","inverseOf":"","subPropertyOf":"prov:wasDerivedFrom","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:measures","label":"measures","s":"pr:Metric","t":"pr:BusinessConcept","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasBIOwner","label":"has BI owner","s":"pr:ConsumptionLayerObject","t":"pr:Actor","comment":"The author or publisher. This is who gets notified by an impact analysis, and it is NOT the Data Domain owner.","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasGrainElement","label":"has grain element","s":"pr:DataObject","t":"pr:DataElement","comment":"The elements whose combination makes a row unique. Grain belongs to a DATASET, not to a calculation rule: a Metric has no rows, so it cannot have a grain of its own -- its grain is derivable from the Data Objects it lands in. Stated at element level rather than object level because it is then CHECKABLE against the harvested PrimaryKey: a declared grain that does not match the real key is an automatic finding.","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:aboutConcept","label":"is about","s":"pr:BusinessObject","t":"pr:BusinessConcept","comment":"The single subject of this Business Object, and where its meaning comes from -- a Business Object carries no definition of its own. Exactly one. If the subject cannot be named, the object is not scoped, and that is the most useful thing the rule can tell you. Replaces the 3..10 minimum of BR-004, which was satisfied in v0.6 by attaching unrelated concepts.","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:operatedBy","label":"operated by","s":"pr:DataProduct","t":"pr:SubDomain","comment":"The single Sub-Domain that runs this product. A Data Product spans several Business Objects and therefore cannot inherit accountability the way a Data Object does -- so it declares its own, and exactly one. It is a consumable: there must be no ambiguity about who runs it.","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:biConsumes","label":"BI consumes","s":"pr:BIDataSource","t":"pr:PhysicalRelation","comment":"","harvest":"Tableau Metadata API (GraphQL) ; Looker Explore SQL","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:inWorkspace","label":"in workspace","s":"pr:ConsumptionLayerObject","t":"pr:BIWorkspace","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasField","label":"has field","s":"pr:PhysicalRelation","t":"pr:Field","comment":"Domain widened from Table to PhysicalRelation so that views and external tables carry columns too. Inverse now declared - in v0.9 hasField and livesIn were inverses in fact but not in the model, so they were free to diverge.","harvest":"","curation":"","inverseOf":"pr:livesIn","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:exposes","label":"exposes","s":"pr:DataProduct","t":"pr:DataInterface","comment":"The channels through which the product is actually consumed.","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:deployedIn","label":"deployed in","s":"pr:ObservedObject","t":"pr:Environment","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:toField","label":"to field","s":"pr:ForeignKeyConstraint","t":"pr:Field","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasDomainOwner","label":"has domain owner","s":"pr:DataDomain","t":"pr:DataDomainOwner","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:servedBy","label":"served by","s":"pr:DataInterface","t":"pr:PhysicalRelation","comment":"What physically backs the interface: the shared relations, the extract source.","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:usesConcept","label":"uses concept","s":"pr:BusinessObject","t":"pr:BusinessConcept","comment":"The other notions required to read this object correctly. Zero or more, and zero is a legitimate answer.","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:constrainsField","label":"constrains field","s":"pr:KeyConstraint","t":"pr:Field","comment":"Several fields for a composite key.","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasBIField","label":"has BI field","s":"pr:BIDataSource","t":"pr:BIField","comment":"","harvest":"","curation":"","inverseOf":"pr:inBIDataSource","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:constrainsRelation","label":"constrains relation","s":"pr:KeyConstraint","t":"pr:PhysicalRelation","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:consumes","label":"consumes","s":"pr:Transformation","t":"pr:PhysicalRelation","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"prov:used","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasActivationStatus","label":"activation status","s":"pr:DataDomain","t":"pr:ActivationStatus","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:livesIn","label":"lives in","s":"pr:Field","t":"pr:PhysicalRelation","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:realizesMetric","label":"realizes metric","s":"pr:BIField","t":"pr:Metric","comment":"The harmonisation measure: how many distinct BI Fields realize one certified Metric, and do their expressions agree. Turns 'we harmonised the definitions' from a claim into a count over the real BI estate.\n\nIn principle this is derivable from the lineage chain Metric -> DataElement -> Field -> BIField, since lineage carries semantic inheritance. In practice that chain will have holes -- four hops, one of them PROPOSED_CONFIRMED, another harvested from an API that does not resolve custom SQL, extracts or cascaded calculated fields -- and the question it answers is too important to depend on them.\n\nSo: TRANSITIONAL CONTROL. Asserted directly, and that is all. No rule is written against the lineage chain: making the assertion conditional on intermediate mappings would create confusion and discourage people from filling it in.\n\nRetirement criterion, to be measured rather than debated: when the lineage chain reproduces the asserted link for more than 95% of certified BI fields across three consecutive reconciliation cycles, the direct link is derivable and can go.","harvest":"","curation":"PROPOSED_CONFIRMED","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:physicalizedIn","label":"physicalized in","s":"pr:DataElement","t":"pr:Field","comment":"Data Element to physical column, 1..n. Proposed by the reconciliation job on fullyQualifiedName and type, confirmed by a Data Steward. This single edge is what makes end-to-end impact analysis possible.","harvest":"","curation":"PROPOSED_CONFIRMED","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasSubDomainOwner","label":"has sub-domain owner","s":"pr:SubDomain","t":"pr:SubDomainOwner","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:hostedOn","label":"hosted on","s":"pr:Database","t":"pr:System","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:governedBy","label":"governed by","s":"pr:DataProduct","t":"pr:DataContract","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:inBIDataSource","label":"in BI data source","s":"pr:BIField","t":"pr:BIDataSource","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:dependsOn","label":"depends on","s":"pr:PhysicalRelation","t":"pr:PhysicalRelation","comment":"Relation-level dependency, view to table. Transitive by nature; in practice compute the closure at query time in Snowflake rather than materialising it with a reasoner.","harvest":"SNOWFLAKE.ACCOUNT_USAGE.OBJECT_DEPENDENCIES","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":true,"deprecated":false,"replacedBy":""},{"id":"pr:hasSystemType","label":"has system type","s":"pr:System","t":"pr:SystemType","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:produces","label":"produces","s":"pr:Transformation","t":"pr:PhysicalRelation","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:usesBIDataSource","label":"uses BI data source","s":"pr:BIReport","t":"pr:BIDataSource","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasProductOwner","label":"has product owner","s":"pr:DataProduct","t":"pr:DataProductOwner","comment":"Exactly one. Belongs to the operating Sub-Domain -- checkable, not just conventional.","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:fromField","label":"from field","s":"pr:ForeignKeyConstraint","t":"pr:Field","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:inDatabase","label":"in database","s":"pr:Schema","t":"pr:Database","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:materializedAs","label":"materialized as","s":"pr:DataObject","t":"pr:PhysicalRelation","comment":"Data Object to physical relation. Not functional: the same Data Object exists in DEV, UAT and PROD, and sometimes across several tables.","harvest":"","curation":"PROPOSED_CONFIRMED","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""}],"universal":[{"id":"pr:belongsTo","label":"belongs to","s":"pr:MetaModelObject","t":"","comment":"Position in the categorization tree. One property for the whole tree: SubDomain belongs to DataDomain, BusinessObject belongs to SubDomain. Scope is enforced per class by SHACL rather than by two near-identical properties, and the same relation will serve if a domain family ever appears. Deliberately NOT transitive: entailment would produce two values and break the maxCount 1 shape. To walk the ancestry, use the property path belongsTo+ in the query.","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:monitoredBy","label":"monitored by","s":"pr:MetaModelObject","t":"pr:DataSteward","comment":"Declared on BusinessObject, and on DataProduct, and NOWHERE ELSE. Data Objects inherit through represents, Data Elements through hasElement. Declaring a steward at several levels would allow two answers to 'who watches this column'.\n\nSays what the steward DOES, where the deprecated hasSteward only stated a membership.\n\nDataProduct is the one exception to inheritance: it packages Data Objects belonging to several Business Objects, so it cannot inherit a single steward and declares its own.","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:describes","label":"describes (deprecated)","s":"pr:MetaModelObject","t":"","comment":"Removed as a genuine relation. Its only use was as a false inverse of composedOf.","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":true,"replacedBy":"pr:isComponentOf"},{"id":"pr:hasGrainDimension","label":"has grain dimension (deprecated)","s":"pr:MetaModelObject","t":"","comment":"Put grain on the Metric, which confounded a declarative statement with a structural fact. See hasGrainElement.","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":true,"replacedBy":"pr:hasGrainElement"},{"id":"pr:represents","label":"represents","s":"pr:MetaModelObject","t":"","comment":"The business notion a logical artefact is the data-side counterpart of. One relation at two granularities:\n DataObject represents BusinessObject (exactly one -- the pivot, BR-006)\n DataElement represents BusinessConcept (at most one)\nRenamed from 'implements', which carried software-engineering baggage: a Data Object does not implement a Business Object the way a class implements an interface.\n\nRange is deliberately left open and scoped by SHACL per class, because the two uses have different targets.\n\nA Data Element uses this ONLY when it is dimensional. A measure element reaches meaning through Metric -> computedBy, which says more: it names WHICH metric the meaning travels through. Never both routes on one element.\n\nFunctional at DataObject level only -- not declared owl:FunctionalProperty here, since a single global cardinality would be wrong for one of the two uses.","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:usesDataObject","label":"uses Data Object (deprecated)","s":"pr:MetaModelObject","t":"","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":true,"replacedBy":"pr:packages"},{"id":"pr:isComponentOf","label":"is component of (deprecated)","s":"pr:MetaModelObject","t":"","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":true,"replacedBy":"pr:usesConcept"},{"id":"pr:hasSteward","label":"has Data Steward (deprecated)","s":"pr:MetaModelObject","t":"","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":true,"replacedBy":"pr:monitoredBy"},{"id":"pr:belongsToDomain","label":"belongs to domain (deprecated)","s":"pr:MetaModelObject","t":"","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":true,"replacedBy":"pr:belongsTo"},{"id":"pr:composedOf","label":"composed of (deprecated)","s":"pr:MetaModelObject","t":"","comment":"Carried three different meanings depending on the object: dependency (Sell Out Volume), subject (Retailer Stock), part-whole (Calendar). One word for three relations is what allowed the v0.6 padding. Split into aboutConcept and usesConcept.","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":true,"replacedBy":"pr:usesConcept"},{"id":"pr:owningDomain","label":"owning Data Domain","s":"pr:MetaModelObject","t":"pr:DataDomain","comment":"The single Data Domain with authority to define the object's rule. Absent on Observed Objects: a Snowflake column is attributed through the Data Element mapped to it, never asserted directly.\n\nUnlike the steward, this one is RE-DECLARABLE on a Data Element. It is inherited by default from the parent chain, but OW-002 attributes an element to the domain that defines its RULE, and a borrowed element -- Brand Code sitting in a Sell Out fact object -- has its rule elsewhere. A Data Element whose owningDomain differs from its parent's is a borrowed element: a signal to check against its represents, not an anomaly to fix.","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:ownedBy","label":"owned by","s":"pr:MetaModelObject","t":"pr:Actor","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""},{"id":"pr:belongsToSubDomain","label":"belongs to sub-domain (deprecated)","s":"pr:MetaModelObject","t":"","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":true,"replacedBy":"pr:belongsTo"},{"id":"pr:implements","label":"implements (deprecated)","s":"pr:MetaModelObject","t":"","comment":"","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":true,"transitive":false,"deprecated":true,"replacedBy":"pr:represents"},{"id":"pr:sourcedFrom","label":"sourced from","s":"pr:MetaModelObject","t":"pr:System","comment":"Replaces the free-text hasSourceSystem. Domain deliberately omitted: applies to Data Elements, Physical Relations and BI Data Sources alike.","harvest":"","curation":"","inverseOf":"","subPropertyOf":"","functional":false,"transitive":false,"deprecated":false,"replacedBy":""}],"datatypes":[{"id":"pr:fullyQualifiedName","label":"fully qualified name","range":"xsd:string","comment":"DATABASE.SCHEMA.RELATION[.COLUMN]. The actual join key against INFORMATION_SCHEMA: a bare physical name is ambiguous as soon as the same column name exists in two tables.","harvest":"","functional":true,"deprecated":false,"replacedBy":""},{"id":"pr:isKPI","label":"is KPI (deprecated)","range":"xsd:boolean","comment":"Duplicated the KPI class. Two ways to say the same thing are two ways to diverge.","harvest":"","functional":false,"deprecated":true,"replacedBy":"pr:KPI"},{"id":"pr:hasGranularity","label":"granularity (deprecated)","range":"xsd:string","comment":"Free-text grain label on a Metric, e.g. 'SKU x Customer x Day'. Kept a second, unverifiable copy of something now stated structurally on the Data Object. Grain is a property of a dataset, not of a calculation rule.","harvest":"","functional":false,"deprecated":true,"replacedBy":"pr:hasGrainElement"},{"id":"pr:hasSourceSystem","label":"source system (deprecated)","range":"xsd:string","comment":"Free text. 'Which objects depend on MDM Product?' was unanswerable because MDM Product was a string, not a node.","harvest":"","functional":false,"deprecated":true,"replacedBy":"pr:sourcedFrom"},{"id":"pr:hasSource","label":"back-doc source","range":"xsd:string","comment":"","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:isNullable","label":"is nullable","range":"xsd:boolean","comment":"","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasVersion","label":"version","range":"xsd:string","comment":"","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:lastReviewedOn","label":"last reviewed on","range":"xsd:date","comment":"","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasAcronym","label":"acronym","range":"xsd:string","comment":"","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasExpression","label":"BI expression","range":"xsd:string","comment":"The calculated-field formula, Tableau or LookML. Compared against the Metric formula to detect definition drift.","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasTimeAggregation","label":"time aggregation","range":"xsd:string","comment":"","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:physicalName","label":"physical name","range":"xsd:string","comment":"Recorded verbatim, never altered (NR-009). Must NOT be carried by a Metric - that is a layer leak; under BR-013 the physical name belongs on the Data Element.","harvest":"","functional":true,"deprecated":false,"replacedBy":""},{"id":"pr:hasUnit","label":"unit","range":"xsd:string","comment":"From the Units LoV. Note the LoV currently mixes physical units, currencies (ISO 4217) and value types (Index, Rate, Count) - three different concepts that should eventually be split.","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasSchemaName","label":"schema name (deprecated)","range":"xsd:string","comment":"","harvest":"","functional":false,"deprecated":true,"replacedBy":"pr:Schema"},{"id":"pr:definedBy","label":"defined by (code URI)","range":"xsd:anyURI","comment":"Git URI of the code behind a Transformation. Connects the graph to the repository.","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:ordinalPosition","label":"ordinal position","range":"xsd:integer","comment":"","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasFormat","label":"logical format","range":"xsd:string","comment":"","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:lastQueriedOn","label":"last queried on","range":"xsd:dateTime","comment":"Lets the model say 'documented but unused for eight months'. A rationalisation argument that costs nothing and is denominated in warehouse spend.","harvest":"SNOWFLAKE.ACCOUNT_USAGE.ACCESS_HISTORY","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasSynonym","label":"synonym","range":"xsd:string","comment":"","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasBusinessDefinition","label":"business definition","range":"xsd:string","comment":"The meaning of a business notion. Belongs to the BusinessConcept.\n\nMUST NOT be carried by a BusinessObject: an object gets its meaning through aboutConcept. Two definitions for one notion are two definitions free to diverge. Enforced by SHACL rather than by rdfs:domain, because Data Elements legitimately carry one too and a declared domain would make them entail BusinessConcept -- which the disjointness axioms would then reject.\n\nMIGRATION: text currently on a Business Object moves to the Concept named by its aboutConcept.","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasStatus","label":"status","range":"xsd:string","comment":"","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasName","label":"canonical name","range":"xsd:string","comment":"","harvest":"","functional":true,"deprecated":false,"replacedBy":""},{"id":"pr:hasIdentifier","label":"identifier","range":"xsd:string","comment":"","harvest":"","functional":true,"deprecated":false,"replacedBy":""},{"id":"pr:approximateRowCount","label":"approximate row count","range":"xsd:integer","comment":"","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasFormula","label":"formula","range":"xsd:string","comment":"","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:arbitrationStatus","label":"arbitration status","range":"xsd:string","comment":"TO_ARBITRATE blocks publication (OW-007). Use this on the object rather than tracking arbitration in a side spreadsheet.","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:sourceIdentifier","label":"source identifier","range":"xsd:string","comment":"Native immutable id in the source system (Snowflake object id, Tableau LUID, Looker id). Makes reconciliation idempotent and survives renames, which names do not.","harvest":"","functional":true,"deprecated":false,"replacedBy":""},{"id":"pr:queryCount30d","label":"query count (30 days)","range":"xsd:integer","comment":"","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasViewDefinition","label":"view definition","range":"xsd:string","comment":"","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasBusinessRule","label":"business rule","range":"xsd:string","comment":"","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:createdOn","label":"created on","range":"xsd:date","comment":"","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasPhysicalDataType","label":"physical data type","range":"xsd:string","comment":"","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasTechnicalDefinition","label":"technical definition","range":"xsd:string","comment":"","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasExampleValue","label":"example value","range":"xsd:string","comment":"","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:hasVendor","label":"vendor","range":"xsd:string","comment":"","harvest":"","functional":false,"deprecated":false,"replacedBy":""},{"id":"pr:harvestedOn","label":"harvested on","range":"xsd:dateTime","comment":"Staleness marker. An Observed Object not refreshed by the last run is a deletion candidate, not a fact.","harvest":"","functional":false,"deprecated":false,"replacedBy":""}],"bridges":[{"id":"pr:denotes","label":"denotes","comment":"Links a documentation record (Business Object, Business Concept, Data Object) to the CLASS it describes in a domain ontology. Example: ex:BO_16_03_001 pr:denotes fin:Invoice ."},{"id":"pr:denotesProperty","label":"denotes property","comment":"Links a Data Element or Business Concept to the PROPERTY it describes in a domain ontology. Example: ex:DE_16_03_0001 pr:denotesProperty fin:netAmount ."},{"id":"pr:denotesActivity","label":"denotes activity","comment":"Links a Metric or Business Object to the PROCESS or EVENT it describes, typically a prov:Activity subclass. Example: ex:M_16_03_001 pr:denotesActivity fin:InvoiceIssuance ."}]};
/* ---------------- state ---------------- */
var NW=168, NH=46, BANDGAP=52, GUT=206;
var byId={}, off={}, mode='strata', showUni=false, showDep=false, showLab=true;
var sel=null, hot=null, view={x:0,y:0,s:1};
var svg=document.getElementById('svg'), vp=document.getElementById('vp');
var bL=document.getElementById('bL'), eL=document.getElementById('eL');
var tL=document.getElementById('tL'), nL=document.getElementById('nL');
var SEAM={'pr:materializedAs':1,'pr:physicalizedIn':1};
M.nodes.forEach(function(n){byId[n.id]=n;});
document.getElementById('s-cls').textContent=M.nodes.length;
document.getElementById('s-obj').textContent=M.edges.length+M.universal.length;
document.getElementById('s-dat').textContent=M.datatypes.length;
document.getElementById('s-brg').textContent=M.edges.filter(function(e){return SEAM[e.id];}).length;
function el(t,a){var e=document.createElementNS('http://www.w3.org/2000/svg',t);
if(a)for(var k in a)e.setAttribute(k,a[k]);return e;}
function bandOf(id){return M.bands.filter(function(b){return b.id===id;})[0];}
function visible(n){ if(off[n.band])return false; if(n.deprecated&&!showDep)return false; return true; }
/* ---------------- layout ---------------- */
function layout(){
var live=M.nodes.filter(visible);
if(mode==='tax'){ layoutTax(live); return live; }
var y=0;
M.bands.forEach(function(b){
var group=live.filter(function(n){return n.band===b.id;});
if(!group.length){b._h=0;b._y=y;return;}
var perRow=Math.max(3,Math.ceil(Math.sqrt(group.length*2.1)));
var rows=Math.ceil(group.length/perRow);
b._y=y; b._h=rows*(NH+20)+48; b._rows=rows; b._per=perRow;
group.forEach(function(n,i){
n.row=Math.floor(i/perRow);
n.x=GUT+(i%perRow)*(NW+26)+NW/2;
n.y=y+34+n.row*(NH+20)+NH/2;
});
y+=b._h+BANDGAP;
});
if(mode==='strata') relax(live);
else force(live);
return live;
}
/* barycentre relaxation on x only: connected classes drift together,
strata stay intact, which is the whole point of the drawing */
function relax(live){
var idx={}; live.forEach(function(n){idx[n.id]=n;});
var adj={}; live.forEach(function(n){adj[n.id]=[];});
M.edges.forEach(function(e){
if(idx[e.s]&&idx[e.t]&&(showDep||!e.deprecated)){
adj[e.s].push(e.t); adj[e.t].push(e.s);}
});
for(var it=0;it<70;it++){
live.forEach(function(n){
var ns=adj[n.id]; if(!ns.length)return;
var sum=0,c=0;
ns.forEach(function(m){if(idx[m]){sum+=idx[m].x;c++;}});
if(c) n.x += (sum/c - n.x)*0.22;
});
/* de-overlap inside each row */
var rows={};
live.forEach(function(n){var k=n.band+'|'+n.row;(rows[k]=rows[k]||[]).push(n);});
for(var k in rows){
var r=rows[k].sort(function(a,b){return a.x-b.x;});
for(var i=1;i<r.length;i++){
var need=(NW+22)-(r[i].x-r[i-1].x);
if(need>0){ r[i].x+=need*0.55; r[i-1].x-=need*0.45; }
}
r.forEach(function(n){ if(n.x<GUT+NW/2) n.x=GUT+NW/2; });
}
}
/* final strict pass: guarantee no overlap, preserve the order relaxation found */
var rows2={};
live.forEach(function(n){var k=n.band+'|'+n.row;(rows2[k]=rows2[k]||[]).push(n);});
for(var k2 in rows2){
var r2=rows2[k2].sort(function(a,b){return a.x-b.x;});
var cursor=Math.max(GUT+NW/2, r2[0].x);
r2.forEach(function(n){ n.x=Math.max(n.x,cursor); cursor=n.x+NW+22; });
}
}
/* plain force-directed network: no strata, for people who want the usual view */
function force(live){
var idx={}; live.forEach(function(n){idx[n.id]=n;});
var links=M.edges.filter(function(e){
return idx[e.s]&&idx[e.t]&&e.s!==e.t&&(showDep||!e.deprecated);});
var cx=live.reduce(function(a,n){return a+n.x;},0)/live.length;
var cy=live.reduce(function(a,n){return a+n.y;},0)/live.length;
for(var it=0;it<420;it++){
var k=1-it/420;
for(var i=0;i<live.length;i++){
var a=live[i];
for(var j=i+1;j<live.length;j++){
var b=live[j], dx=b.x-a.x, dy=(b.y-a.y)*1.9;
var d2=dx*dx+dy*dy||1, f=42000/d2;
if(f>9)f=9;
var d=Math.sqrt(d2);
a.x-=dx/d*f; a.y-=dy/d*f*0.5; b.x+=dx/d*f; b.y+=dy/d*f*0.5;
}
a.x+=(cx-a.x)*0.004*k; a.y+=(cy-a.y)*0.006*k;
}
links.forEach(function(e){
var a=idx[e.s], b=idx[e.t], dx=b.x-a.x, dy=b.y-a.y;
var d=Math.sqrt(dx*dx+dy*dy)||1, f=(d-235)*0.018*k;
a.x+=dx/d*f; a.y+=dy/d*f; b.x-=dx/d*f; b.y-=dy/d*f;
});
}
}
function layoutTax(live){
var kids={}, roots=[];
live.forEach(function(n){ if(n.parent&&byId[n.parent]&&visible(byId[n.parent]))
(kids[n.parent]=kids[n.parent]||[]).push(n); else roots.push(n); });
var cursor=0;
function place(n,depth){
var ch=kids[n.id]||[];
n.x=depth*(NW+58)+NW/2+30;
if(!ch.length){ n.y=cursor*(NH+13)+NH/2+20; cursor++; }
else{ ch.forEach(function(c){place(c,depth+1);});
n.y=(ch[0].y+ch[ch.length-1].y)/2; }
}
roots.forEach(function(r){place(r,0); cursor+=0.7;});
M.bands.forEach(function(b){b._h=0;});
}
/* ---------------- render ---------------- */
function render(){
var live=layout();
var idx={}; live.forEach(function(n){idx[n.id]=n;});
bL.innerHTML=''; eL.innerHTML=''; tL.innerHTML=''; nL.innerHTML='';
if(mode==='strata'){
var maxx=Math.max.apply(null,live.map(function(n){return n.x;}).concat([600]))+NW;
M.bands.forEach(function(b){
if(!b._h)return;
bL.appendChild(el('rect',{x:0,y:b._y,width:maxx+60,height:b._h,
fill:b.color,opacity:.045,class:'bandrect'}));
bL.appendChild(el('line',{x1:0,y1:b._y,x2:maxx+60,y2:b._y,class:'bandline'}));
bL.appendChild(el('rect',{x:GUT-5,y:b._y+10,width:3,height:b._h-22,
fill:b.color,opacity:.85}));
var t=el('text',{x:GUT-20,y:b._y+26,class:'bandlabel',fill:b.color,
'text-anchor':'end'});
t.textContent=b.title; bL.appendChild(t);
var g=el('text',{x:GUT-20,y:b._y+41,class:'bandtag','text-anchor':'end'});
g.textContent=b.tag; bL.appendChild(g);
});
}
var list=M.edges.slice();
if(showUni) list=list.concat(M.universal);
list.forEach(function(e){
if(!idx[e.s]||!idx[e.t])return;
if(e.deprecated&&!showDep)return;
var a=idx[e.s], b=idx[e.t], uni=M.universal.indexOf(e)>-1;
var cls='edge'+(uni?' uni':SEAM[e.id]?' bridge':'');
var p, mx, my;
if(a===b){
p='M'+(a.x-24)+','+(a.y-NH/2)+' C'+(a.x-70)+','+(a.y-70)+' '+(a.x+70)+','+(a.y-70)+' '+(a.x+24)+','+(a.y-NH/2);
mx=a.x; my=a.y-52;
}else{
var dx=b.x-a.x, dy=b.y-a.y, k=Math.abs(dy)>4?0.38:0;
var cx=a.x+dx/2+(-dy)*k*0.14, cy=a.y+dy/2+dx*k*0.05;
p='M'+a.x+','+a.y+' Q'+cx+','+cy+' '+b.x+','+b.y;
mx=(a.x+2*cx+b.x)/4; my=(a.y+2*cy+b.y)/4;
}
var path=el('path',{d:p,class:cls}); path.dataset.e=e.id;
path.dataset.a=e.s; path.dataset.b=e.t; eL.appendChild(path);
if(showLab&&!uni){
var w=e.label.length*4.6+8;
var bg=el('rect',{x:mx-w/2,y:my-6,width:w,height:11,rx:2,class:'ebg'});
bg.dataset.el=e.id; tL.appendChild(bg);
var tx=el('text',{x:mx,y:my+2.5,class:'elabel'});
tx.dataset.el=e.id; tx.textContent=e.label; tL.appendChild(tx);
}
});
live.forEach(function(n){
var col=bandOf(n.band).color;
var g=el('g',{class:'node','tabindex':'0','role':'button',
'aria-label':n.label,transform:'translate('+(n.x-NW/2)+','+(n.y-NH/2)+')'});
g.dataset.id=n.id;
g.appendChild(el('rect',{width:NW,height:NH,rx:3,
fill:n.prov==='observed'?'#fff':col,
'fill-opacity':n.prov==='observed'?1:.13,
stroke:col,'stroke-dasharray':n.deprecated?'4 3':'none'}));
if(n.prov==='observed')
g.appendChild(el('rect',{width:NW,height:NH,rx:3,fill:'url(#hatch)',stroke:'none'}));
var lines=wrap(n.label,22);
lines.forEach(function(s,i){
var t=el('text',{x:NW/2,y:NH/2+4+(i-(lines.length-1)/2)*11.8,
fill:n.deprecated?'#9A9A9A':'#1A1A1A',
'text-decoration':n.deprecated?'line-through':'none'});
t.textContent=s; g.appendChild(t);
});
nL.appendChild(g);
});
applyHot();
}
function wrap(s,max){
var w=s.split(' '), out=[], cur='';
w.forEach(function(x){
if((cur+' '+x).trim().length>max){ if(cur)out.push(cur); cur=x; }
else cur=(cur+' '+x).trim();
});
if(cur)out.push(cur);
return out.slice(0,3);
}
/* ---------------- focus / highlight ---------------- */
function neighbours(id){
var s={}; s[id]=1;
M.edges.concat(M.universal).forEach(function(e){
if(e.s===id)s[e.t]=1; if(e.t===id)s[e.s]=1;});
return s;
}
function applyHot(){
var id=hot||sel;
var nodes=nL.querySelectorAll('.node');
var paths=eL.querySelectorAll('.edge');
var labs=tL.querySelectorAll('[data-el]');
if(!id){
nodes.forEach(function(n){n.classList.remove('dim','sel');});
paths.forEach(function(p){p.classList.remove('dim','hot');});
labs.forEach(function(l){l.classList.remove('dim');});
return;
}
var keep=neighbours(id), live={};
paths.forEach(function(p){
var on=(p.dataset.a===id||p.dataset.b===id);
p.classList.toggle('hot',on); p.classList.toggle('dim',!on);
if(on)live[p.dataset.e]=1;
});
labs.forEach(function(l){l.classList.toggle('dim',!live[l.dataset.el]);});
nodes.forEach(function(n){
n.classList.toggle('dim',!keep[n.dataset.id]);
n.classList.toggle('sel',n.dataset.id===sel);
});
}
/* ---------------- inspector ---------------- */
var insp=document.getElementById('insp'), ibody=document.getElementById('insp-body');
function esc(s){return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;');}
function open(id){
var n=byId[id]; if(!n)return;
sel=id; var b=bandOf(n.band), h=[];
h.push('<div class="kicker" style="color:'+b.color+'">'+esc(b.title)+'</div>');
h.push('<h3>'+esc(n.label)+'</h3>');
h.push('<div class="iri">'+esc(n.id)+'</div>');
h.push('<span class="pill" style="color:'+b.color+';border-color:'+b.color+'">'+
(n.prov==='observed'?'Observed &middot; harvested':n.prov==='curated'?'Curated &middot; asserted':'Structural')+'</span>');
if(n.curation) h.push('<span class="pill" style="color:#B8935A;border-color:#B8935A">'+esc(n.curation)+'</span>');
if(n.deprecated) h.push('<span class="pill" style="color:#8B3A3A;border-color:#8B3A3A">Deprecated</span>');
if(n.comment) h.push('<h4>What it is for</h4><p>'+esc(n.comment)+'</p>');
if(n.replacedBy) h.push('<p><b>Replaced by</b> '+link(n.replacedBy)+'</p>');
if(n.harvest) h.push('<h4>Harvested from</h4><div class="harvest">'+esc(n.harvest)+'</div>');
if(n.parent) h.push('<h4>Specialises</h4><div class="rel"><span class="p">rdfs:subClassOf</span><span class="o">'+link(n.parent)+'</span></div>');
var kids=M.nodes.filter(function(m){return m.parent===id;});
if(kids.length) h.push('<h4>Specialised by</h4>'+kids.map(function(k){
return '<div class="rel"><span class="p">'+esc(k.label)+'</span><span class="o">'+
(k.deprecated?'deprecated':bandOf(k.band).title)+'</span></div>';}).join(''));
if(n.members&&n.members.length) h.push('<h4>Allowed values</h4>'+n.members.map(function(m){
return '<div class="rel"><span class="p">'+esc(m.label)+'</span><span class="o">'+esc(m.id)+'</span></div>';}).join(''));
var out=M.edges.concat(M.universal).filter(function(e){return e.s===id&&(showDep||!e.deprecated);});
var inc=M.edges.concat(M.universal).filter(function(e){return e.t===id&&(showDep||!e.deprecated);});
if(out.length) h.push('<h4>Points to</h4>'+out.map(rel).join(''));
if(inc.length) h.push('<h4>Pointed to by</h4>'+inc.map(function(e){
return '<div class="rel"><span class="p">'+esc(e.label)+'</span><span class="o">'+link(e.s)+'</span></div>';}).join(''));
ibody.innerHTML=h.join('');
insp.classList.add('open');
bindJumps(); applyHot();
}
function rel(e){
var flags=[]; if(e.functional)flags.push('exactly one');
if(e.transitive)flags.push('transitive');
if(SEAM[e.id])flags.push('seam');
return '<div class="rel"><span class="p">'+esc(e.label)+
(flags.length?' <span class="flag">'+flags.join(' &middot; ')+'</span>':'')+
'</span><span class="o">'+link(e.t)+'</span>'+
(e.comment?'<p style="grid-column:1/-1;margin:4px 0 0;font-size:11.5px;color:#6B6B6B">'+esc(e.comment)+'</p>':'')+
'</div>';
}
function link(id){
var n=byId[id];
return n?'<button class="jump" data-j="'+id+'">'+esc(n.label)+'</button>':esc(id);
}
function bindJumps(){
ibody.querySelectorAll('.jump').forEach(function(b){
b.onclick=function(){open(b.dataset.j); focusOn(b.dataset.j);};});
}
document.getElementById('insp-x').onclick=function(){
insp.classList.remove('open'); sel=null; applyHot();};
document.addEventListener('keydown',function(e){
if(e.key==='Escape'){insp.classList.remove('open'); sel=null; applyHot();}});
/* ---------------- view ---------------- */
function apply(){vp.setAttribute('transform','translate('+view.x+','+view.y+') scale('+view.s+')');}
function fit(){
var live=M.nodes.filter(visible); if(!live.length)return;
var xs=live.map(function(n){return n.x;}), ys=live.map(function(n){return n.y;});
var pad=(mode==='strata')?GUT+30:NW;
var mnx=Math.min.apply(null,xs)-pad, mxx=Math.max.apply(null,xs)+NW;
var mny=Math.min.apply(null,ys)-90, mxy=Math.max.apply(null,ys)+70;
var r=svg.getBoundingClientRect();
view.s=Math.min(r.width/(mxx-mnx),r.height/(mxy-mny),1.25);
view.x=(r.width-(mxx-mnx)*view.s)/2-mnx*view.s;
view.y=(r.height-(mxy-mny)*view.s)/2-mny*view.s;
apply();
}
function focusOn(id){
var n=byId[id]; if(!n)return;
var r=svg.getBoundingClientRect();
view.x=r.width/2-n.x*view.s-120; view.y=r.height/2-n.y*view.s; apply();
}
document.getElementById('z-in').onclick=function(){zoom(1.22);};
document.getElementById('z-out').onclick=function(){zoom(.82);};
document.getElementById('z-fit').onclick=fit;
function zoom(f){
var r=svg.getBoundingClientRect(), cx=r.width/2, cy=r.height/2;
var ns=Math.max(.18,Math.min(view.s*f,2.6));
view.x=cx-(cx-view.x)*(ns/view.s); view.y=cy-(cy-view.y)*(ns/view.s);
view.s=ns; apply();
}
var drag=false,ds,vs,pinch=null;
svg.addEventListener('mousedown',function(e){drag=true;svg.classList.add('drag');
ds={x:e.clientX,y:e.clientY};vs={x:view.x,y:view.y};});
window.addEventListener('mousemove',function(e){if(!drag)return;
view.x=vs.x+(e.clientX-ds.x);view.y=vs.y+(e.clientY-ds.y);apply();});
window.addEventListener('mouseup',function(){drag=false;svg.classList.remove('drag');});
svg.addEventListener('wheel',function(e){e.preventDefault();
var f=e.deltaY<0?1.1:.9, r=svg.getBoundingClientRect();
var mx=e.clientX-r.left,my=e.clientY-r.top;
var ns=Math.max(.18,Math.min(view.s*f,2.6));
view.x=mx-(mx-view.x)*(ns/view.s);view.y=my-(my-view.y)*(ns/view.s);
view.s=ns;apply();},{passive:false});
svg.addEventListener('touchstart',function(e){
if(e.touches.length===1){drag=true;ds={x:e.touches[0].clientX,y:e.touches[0].clientY};
vs={x:view.x,y:view.y};}
else if(e.touches.length===2){drag=false;
var dx=e.touches[0].clientX-e.touches[1].clientX,dy=e.touches[0].clientY-e.touches[1].clientY;
pinch={d:Math.sqrt(dx*dx+dy*dy),s:view.s};}},{passive:true});
svg.addEventListener('touchmove',function(e){
if(e.touches.length===1&&drag){view.x=vs.x+(e.touches[0].clientX-ds.x);
view.y=vs.y+(e.touches[0].clientY-ds.y);apply();e.preventDefault();}
else if(e.touches.length===2&&pinch){
var dx=e.touches[0].clientX-e.touches[1].clientX,dy=e.touches[0].clientY-e.touches[1].clientY;
var nd=Math.sqrt(dx*dx+dy*dy),ns=Math.max(.18,Math.min(pinch.s*(nd/pinch.d),2.6));
var r=svg.getBoundingClientRect(),cx=r.width/2,cy=r.height/2;
view.x=cx-(cx-view.x)*(ns/view.s);view.y=cy-(cy-view.y)*(ns/view.s);
view.s=ns;apply();e.preventDefault();}},{passive:false});
svg.addEventListener('touchend',function(){drag=false;pinch=null;});
nL.addEventListener('click',function(e){
var g=e.target.closest('.node'); if(g)open(g.dataset.id);});
nL.addEventListener('keydown',function(e){
var g=e.target.closest('.node');
if(g&&(e.key==='Enter'||e.key===' ')){e.preventDefault();open(g.dataset.id);}});
nL.addEventListener('mouseover',function(e){
var g=e.target.closest('.node'); if(g&&!sel){hot=g.dataset.id;applyHot();}});
nL.addEventListener('mouseout',function(e){
if(!e.relatedTarget||!e.relatedTarget.closest||!e.relatedTarget.closest('.node')){
if(!sel){hot=null;applyHot();}}});
/* ---------------- controls ---------------- */
var bandsBox=document.getElementById('bands');
M.bands.forEach(function(b){
var n=M.nodes.filter(function(x){return x.band===b.id;}).length;
if(!n)return;
var btn=document.createElement('button');
btn.className='chip'; btn.setAttribute('aria-pressed','true');
btn.innerHTML='<span class="swatch" style="background:'+b.color+'"></span>'+
b.title+'<span class="n">'+n+'</span>';
btn.onclick=function(){
off[b.id]=!off[b.id];
btn.classList.toggle('off',!!off[b.id]);
btn.setAttribute('aria-pressed',String(!off[b.id]));
render(); fit();};
bandsBox.appendChild(btn);
});
function setMode(m){
mode=m;
['strata','tax','free'].forEach(function(k){
document.getElementById('m-'+k).classList.toggle('on',k===m);});
render(); fit();
}
document.getElementById('m-strata').onclick=function(){setMode('strata');};
document.getElementById('m-tax').onclick=function(){setMode('tax');};
document.getElementById('m-free').onclick=function(){setMode('free');};
document.getElementById('o-uni').onchange=function(){showUni=this.checked;render();};
document.getElementById('o-dep').onchange=function(){showDep=this.checked;render();fit();};
document.getElementById('o-lab').onchange=function(){showLab=this.checked;render();};
document.getElementById('mtoggle').onclick=function(){
document.getElementById('side').classList.toggle('open');};
document.getElementById('q').addEventListener('input',function(){
var v=this.value.trim().toLowerCase();
if(!v){hot=null;applyHot();return;}
var hit=M.nodes.filter(visible).filter(function(n){
return (n.label+' '+n.id+' '+(n.comment||'')).toLowerCase().indexOf(v)>-1;});
nL.querySelectorAll('.node').forEach(function(g){
var on=hit.some(function(n){return n.id===g.dataset.id;});
g.classList.toggle('dim',!on);});
if(hit.length===1){open(hit[0].id);focusOn(hit[0].id);}
});
render(); fit();
window.addEventListener('resize',fit);
</script>
</body>
</html>