Files
data-meta-model/docs/sodh_viewer_legacy_v0.6.html
T

260 lines
44 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SODH Data - Instance Graph Viewer (A-Box)</title>
<style>
:root{--navy:#0A1F44;--gold:#B8935A;--blue:#1E4B9B;--green:#1E7A5F;--purple:#7A4E8B;--rust:#8B3A3A;--grey:#6B6B6B;--bg:#F7F6F2;--white:#FFF;--muted:#6B6B6B;}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
html,body{height:100%;margin:0;padding:0;}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;background:var(--bg);color:#1A1A1A;display:flex;flex-direction:column;overflow:hidden;}
header{background:var(--navy);color:#fff;padding:12px 20px;display:flex;align-items:center;justify-content:space-between;box-shadow:0 2px 6px rgba(0,0,0,.15);flex-shrink:0;}
header h1{margin:0;font-size:17px;font-weight:600;}
header .subtitle{font-size:11px;color:rgba(255,255,255,.7);margin-top:2px;}
header .badge{background:var(--gold);color:var(--navy);padding:4px 10px;border-radius:10px;font-size:10px;font-weight:700;white-space:nowrap;}
main{flex:1;display:flex;overflow:hidden;min-height:0;position:relative;}
aside{width:280px;background:var(--white);border-right:1px solid #E4E2DA;overflow-y:auto;padding:16px;flex-shrink:0;-webkit-overflow-scrolling:touch;}
aside h2{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);margin:0 0 8px 0;font-weight:700;}
aside h2:not(:first-of-type){margin-top:20px;}
.legend-row{display:flex;align-items:center;gap:8px;padding:4px 0;font-size:12px;cursor:pointer;}
.legend-dot{width:13px;height:13px;border-radius:50%;flex-shrink:0;border:2px solid rgba(0,0,0,.15);}
.legend-row.off{opacity:.35;}
.filter-row{display:flex;align-items:center;justify-content:space-between;padding:5px 0;font-size:12px;}
.filter-row input{width:17px;height:17px;cursor:pointer;accent-color:var(--blue);}
.controls{display:flex;flex-direction:column;gap:6px;}
.btn{background:var(--blue);color:#fff;border:none;padding:9px 12px;border-radius:4px;font-size:12px;font-weight:500;cursor:pointer;text-align:center;font-family:inherit;}
.btn.secondary{background:transparent;color:var(--blue);border:1px solid var(--blue);}
.btn.active{background:var(--navy);}
#wrap{flex:1;min-width:0;background:linear-gradient(180deg,#FCFBF7,#F0EEE4);position:relative;overflow:hidden;touch-action:none;}
svg{display:block;width:100%;height:100%;cursor:grab;}
svg.drag{cursor:grabbing;}
.node{cursor:pointer;}
.node:hover circle{filter:brightness(1.15);}
.nlabel{fill:#111;font-size:9px;pointer-events:none;text-anchor:middle;font-family:inherit;}
.elabel{font-size:8px;fill:#444;text-anchor:middle;pointer-events:none;}
.elabel-bg{fill:#F7F6F2;}
#zoom{position:absolute;bottom:16px;left:16px;display:flex;flex-direction:column;gap:4px;background:#fff;border-radius:6px;box-shadow:0 2px 8px rgba(0,0,0,.12);padding:4px;}
#zoom button{width:34px;height:34px;background:#fff;border:none;border-radius:4px;font-size:17px;cursor:pointer;color:var(--navy);font-weight:600;}
#zoom button:hover{background:#f0f0f0;}
#info{position:absolute;bottom:16px;right:16px;width:330px;max-width:calc(100vw - 32px);max-height:68vh;background:#fff;border-radius:6px;box-shadow:0 4px 20px rgba(10,31,68,.18);padding:16px;overflow-y:auto;display:none;-webkit-overflow-scrolling:touch;}
#info h3{margin:0 0 4px 0;font-size:15px;color:var(--navy);}
#info .tag{display:inline-block;padding:3px 8px;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;border-radius:3px;color:#fff;margin:0 4px 8px 0;}
#info h4{font-size:10px;text-transform:uppercase;letter-spacing:.8px;color:var(--muted);margin:10px 0 4px 0;font-weight:700;}
#info .kv{font-size:12px;line-height:1.5;margin-bottom:3px;}
#info .kv b{color:#333;}
#info ul{margin:0;padding-left:16px;font-size:11px;}
#info .close{position:absolute;top:8px;right:10px;cursor:pointer;font-size:22px;color:var(--muted);border:none;background:none;padding:4px 8px;}
#colormode{margin-top:8px;font-size:12px;}
footer{background:var(--navy);color:rgba(255,255,255,.6);padding:6px 20px;font-size:10px;display:flex;justify-content:space-between;flex-shrink:0;}
#mtoggle{display:none;background:transparent;color:#fff;border:1px solid rgba(255,255,255,.4);padding:4px 10px;border-radius:4px;font-size:11px;font-weight:600;cursor:pointer;margin-right:10px;}
@media(max-width:700px){aside{position:absolute;z-index:20;height:100%;transform:translateX(-100%);transition:transform .25s;width:255px;box-shadow:4px 0 16px rgba(0,0,0,.15);}aside.open{transform:translateX(0);}#mtoggle{display:inline-block;}header h1{font-size:14px;}#info{width:calc(100vw - 32px);}}
</style>
</head>
<body>
<header>
<div style="display:flex;align-items:center;">
<button id="mtoggle" onclick="document.querySelector('aside').classList.toggle('open')">&#9776; Menu</button>
<div><h1>SODH Data &middot; Instance Graph (A-Box)</h1>
<div class="subtitle">Sell Out Data Hub back-documentation &mdash; NODES &middot; woven relationships</div></div>
</div>
<span class="badge">v0.6 &middot; 23 JUL 2026</span>
</header>
<main>
<aside>
<h2>Object type</h2>
<div id="type-legend"></div>
<h2>Colour nodes by</h2>
<div id="colormode">
<label><input type="radio" name="cmode" value="type" checked> Object type</label><br>
<label><input type="radio" name="cmode" value="domain"> Owning Data Domain</label>
</div>
<h2>Show relations</h2>
<div id="rel-filters"></div>
<h2>Layout</h2>
<div class="controls">
<button class="btn active" id="b-force" onclick="setLayout('force')">Force (network)</button>
<button class="btn" id="b-layer" onclick="setLayout('layer')">Layered (by class)</button>
<button class="btn secondary" onclick="resetView()">Reset view</button>
</div>
<h2>About</h2>
<div style="font-size:11px;color:var(--muted);line-height:1.5">Tap a node for its attributes and connections. This is the instance graph (A-Box) &mdash; the actual SODH objects and the relationships woven between them.<br><br>Owner: <b>B. Gourdon</b></div>
</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="#888"/></marker>
</defs>
<g id="vp"><g id="eL"></g><g id="nL"></g></g>
</svg>
<div id="zoom"><button onclick="zoom(1.25)">+</button><button onclick="zoom(.8)">&minus;</button><button onclick="resetView()" style="font-size:13px">&#8635;</button></div>
<div id="info"><button class="close" onclick="document.getElementById('info').style.display='none'">&times;</button><div id="ic"></div></div>
</div>
</main>
<footer><span>Self-contained SVG viewer &middot; zero dependency</span><span>86 nodes &middot; 131 edges</span></footer>
<script>
var GRAPH = {"nodes": [{"id": "DD-06", "label": "DD-06\nSales Performance", "type": "DataDomain", "domain": "DD-06", "extra": {"DGL": "Bastien Gourdon", "Activation": "FullActivation"}}, {"id": "DD-10", "label": "DD-10\nProduct & Material", "type": "DataDomain", "domain": "DD-10", "extra": {"DGL": "Anas El Kesri", "Activation": "FullActivation"}}, {"id": "DD-04", "label": "DD-04\nCustomer & Channels", "type": "DataDomain", "domain": "DD-04", "extra": {"DGL": "TBD (vacant)", "Activation": "LightActivation"}}, {"id": "DD-16", "label": "DD-16\nFinancial Performance", "type": "DataDomain", "domain": "DD-16", "extra": {"DGL": "Gaelle Seret", "Activation": "FullActivation"}}, {"id": "DD-21", "label": "DD-21\nData Governance", "type": "DataDomain", "domain": "DD-21", "extra": {"DGL": "Bastien Gourdon", "Activation": "FullActivation"}}, {"id": "DD-05", "label": "DD-05\nSales Activation", "type": "DataDomain", "domain": "DD-05", "extra": {"DGL": "Alberic Piot", "Activation": "FullActivation"}}, {"id": "SD-06.01", "label": "SD-06.01\nSell Out", "type": "SubDomain", "domain": "DD-06", "extra": {"parent": "DD-06"}}, {"id": "SD-10.01", "label": "SD-10.01\nProduct Structure", "type": "SubDomain", "domain": "DD-10", "extra": {"parent": "DD-10"}}, {"id": "SD-04.01", "label": "SD-04.01\nCustomer & Outlet", "type": "SubDomain", "domain": "DD-04", "extra": {"parent": "DD-04"}}, {"id": "SD-16.01", "label": "SD-16.01\nCurrency & Exchange Rates", "type": "SubDomain", "domain": "DD-16", "extra": {"parent": "DD-16"}}, {"id": "SD-16.02", "label": "SD-16.02\nMarketing Entity & Fiscal Calendar", "type": "SubDomain", "domain": "DD-16", "extra": {"parent": "DD-16"}}, {"id": "SD-21.01", "label": "SD-21.01\nCalendar", "type": "SubDomain", "domain": "DD-21", "extra": {"parent": "DD-21"}}, {"id": "SD-05.01", "label": "SD-05.01\nPromotion", "type": "SubDomain", "domain": "DD-05", "extra": {"parent": "DD-05"}}, {"id": "BC-06.01-001", "label": "Sell Out", "type": "BusinessConcept", "domain": "DD-06", "extra": {"definition": "The sale of Pernod Ricard products by retail customers to end consumers, observed via panel or direct feed, as opposed to shipments from Pernod Ricard to its customers (Sell In).", "subdomain": "SD-06.01"}}, {"id": "BC-06.01-002", "label": "Retailer Stock", "type": "BusinessConcept", "domain": "DD-06", "extra": {"definition": "Inventory of Pernod Ricard products held at the retailer level, downstream of Pernod Ricard shipments.", "subdomain": "SD-06.01"}}, {"id": "BC-06.01-003", "label": "Panel Coverage", "type": "BusinessConcept", "domain": "DD-06", "extra": {"definition": "The scope of retail outlets observed by a panel provider, defining the universe over which distribution is computed.", "subdomain": "SD-06.01"}}, {"id": "BC-06.01-004", "label": "Distribution", "type": "BusinessConcept", "domain": "DD-06", "extra": {"definition": "The presence of a product across retail outlets: the number of points of sale carrying at least one Pernod Ricard product, related to the total number of points of sale in the Pernod Ricard universe.", "subdomain": "SD-06.01"}}, {"id": "BC-05.01-001", "label": "Promotion", "type": "BusinessConcept", "domain": "DD-05", "extra": {"definition": "A commercial mechanism applied at retail to stimulate sales (for example Temporary Price Reduction, Multibuy, Feature Display).", "subdomain": "SD-05.01"}}, {"id": "BO-06.01-001", "label": "Sell Out Volume", "type": "BusinessObject", "domain": "DD-06", "extra": {"definition": "Quantities of Pernod Ricard products sold by retail customers to end consumers, observed at retail.", "steward": "Sell Out Steward", "source": "SODH Gold / F_SELL_OUT"}}, {"id": "BO-06.01-002", "label": "Sell Out Value", "type": "BusinessObject", "domain": "DD-06", "extra": {"definition": "Monetary value of Pernod Ricard products sold to end consumers, expressed in multiple currencies (Euro, US Dollar, or local currency).", "steward": "Sell Out Steward", "source": "SODH Gold / F_SELL_OUT"}}, {"id": "BO-06.01-003", "label": "Sell Out Distribution", "type": "BusinessObject", "domain": "DD-06", "
var TYPE_COLOR = {
DataDomain:'#0A1F44', SubDomain:'#33518A', BusinessObject:'#1E4B9B',
BusinessConcept:'#B8935A', Metric:'#C0392B', DataElement:'#1E7A5F',
DataObject:'#7A4E8B'
};
var DOMAIN_COLOR = {
'DD-06':'#0A1F44','DD-10':'#B8935A','DD-04':'#1E4B9B',
'DD-16':'#1E7A5F','DD-21':'#6B6B6B','DD-05':'#7A4E8B'
};
var REL_COLOR = {
belongsToDomain:'#888', belongsToSubDomain:'#888',
composedOf:'#B8935A', hasMetric:'#C0392B', measures:'#C0392B',
implements:'#7A4E8B', hasElement:'#1E7A5F', references:'#1E4B9B'
};
var TYPES = ['DataDomain','SubDomain','BusinessObject','BusinessConcept','Metric','DataElement','DataObject'];
var RELS = ['belongsToDomain','belongsToSubDomain','composedOf','hasMetric','measures','implements','hasElement','references'];
var SVGNS='http://www.w3.org/2000/svg';
var svg=document.getElementById('svg'), vp=document.getElementById('vp'), eL=document.getElementById('eL'), nL=document.getElementById('nL');
var pos={}, view={x:0,y:0,scale:1}, colorMode='type', layout='force';
var typeOn={}, relOn={};
TYPES.forEach(function(t){typeOn[t]=true;});
RELS.forEach(function(r){relOn[r]=true;});
function el(t,a){var e=document.createElementNS(SVGNS,t);if(a)for(var k in a)e.setAttribute(k,a[k]);return e;}
function nodeColor(n){return colorMode==='type'?(TYPE_COLOR[n.type]||'#555'):(DOMAIN_COLOR[n.domain]||'#555');}
function esc(s){return String(s).replace(/[&<>"']/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
// ---- Force layout (simple Fruchterman-Reingold style) ----
function forceLayout(){
var N=GRAPH.nodes, E=GRAPH.edges, W=1400, H=1000;
N.forEach(function(n,i){ if(!pos[n.id]) pos[n.id]={x:W/2+Math.cos(i)*300+(Math.random()-.5)*80, y:H/2+Math.sin(i)*300+(Math.random()-.5)*80};});
var k=110;
for(var it=0; it<220; it++){
var disp={}; N.forEach(function(n){disp[n.id]={x:0,y:0};});
for(var a=0;a<N.length;a++)for(var b=a+1;b<N.length;b++){
var na=N[a].id, nb=N[b].id, dx=pos[na].x-pos[nb].x, dy=pos[na].y-pos[nb].y;
var d=Math.sqrt(dx*dx+dy*dy)||.01, rep=k*k/d;
var ux=dx/d, uy=dy/d;
disp[na].x+=ux*rep; disp[na].y+=uy*rep; disp[nb].x-=ux*rep; disp[nb].y-=uy*rep;
}
E.forEach(function(e){
if(!pos[e.s]||!pos[e.t])return;
var dx=pos[e.s].x-pos[e.t].x, dy=pos[e.s].y-pos[e.t].y, d=Math.sqrt(dx*dx+dy*dy)||.01;
var att=d*d/k, ux=dx/d, uy=dy/d;
disp[e.s].x-=ux*att; disp[e.s].y-=uy*att; disp[e.t].x+=ux*att; disp[e.t].y+=uy*att;
});
var t=Math.max(2,30*(1-it/220));
N.forEach(function(n){
var dd=disp[n.id], len=Math.sqrt(dd.x*dd.x+dd.y*dd.y)||.01;
pos[n.id].x+=dd.x/len*Math.min(len,t); pos[n.id].y+=dd.y/len*Math.min(len,t);
});
}
}
// ---- Layered layout (by class row) ----
function layerLayout(){
var rows={DataDomain:0,SubDomain:1,BusinessObject:2,BusinessConcept:3,Metric:4,DataObject:5,DataElement:6};
var buckets={}; GRAPH.nodes.forEach(function(n){var r=rows[n.type];(buckets[r]=buckets[r]||[]).push(n);});
var maxW=0; Object.keys(buckets).forEach(function(r){maxW=Math.max(maxW,buckets[r].length);});
var Wpx=Math.max(1400,maxW*95);
Object.keys(buckets).forEach(function(r){
var arr=buckets[r], sp=Wpx/(arr.length+1);
arr.forEach(function(n,i){ pos[n.id]={x:sp*(i+1), y:90+parseInt(r)*135};});
});
}
function nodeRadius(n){return n.type==='DataDomain'?26:(n.type==='SubDomain'?22:(n.type==='BusinessObject'||n.type==='DataObject'?18:14));}
function render(){
eL.innerHTML=''; nL.innerHTML='';
// edges
GRAPH.edges.forEach(function(e){
if(!relOn[e.p])return;
var s=GRAPH.nodes.find(function(n){return n.id===e.s;}), t=GRAPH.nodes.find(function(n){return n.id===e.t;});
if(!s||!t||!typeOn[s.type]||!typeOn[t.type])return;
if(!pos[e.s]||!pos[e.t])return;
var x1=pos[e.s].x,y1=pos[e.s].y,x2=pos[e.t].x,y2=pos[e.t].y;
var dx=x2-x1,dy=y2-y1,d=Math.sqrt(dx*dx+dy*dy)||1, r=nodeRadius(t)+5;
var ex=x2-dx/d*r, ey=y2-dy/d*r;
eL.appendChild(el('line',{x1:x1,y1:y1,x2:ex,y2:ey,stroke:REL_COLOR[e.p]||'#999','stroke-width':1.2,'marker-end':'url(#ar)',opacity:.7}));
});
// nodes
GRAPH.nodes.forEach(function(n){
if(!typeOn[n.type]||!pos[n.id])return;
var p=pos[n.id], r=nodeRadius(n);
var g=el('g',{class:'node',transform:'translate('+p.x+','+p.y+')'});
g.appendChild(el('circle',{r:r,fill:nodeColor(n),stroke:'#1a1a1a','stroke-width':1}));
var short=n.label.split('\n')[0];
if(short.length>16) short=short.slice(0,15)+'';
var tx=el('text',{class:'nlabel',y:r+10}); tx.textContent=short; g.appendChild(tx);
g.addEventListener('click',function(ev){ev.stopPropagation();showInfo(n);});
nL.appendChild(g);
});
}
function showInfo(n){
var out=GRAPH.edges.filter(function(e){return e.s===n.id;}).map(function(e){return '<code>'+e.p+'</code> &rarr; '+e.t;});
var inc=GRAPH.edges.filter(function(e){return e.t===n.id;}).map(function(e){return e.s+' <code>'+e.p+'</code>';});
var col=nodeColor(n);
var h='<span class="tag" style="background:'+col+'">'+n.type+'</span><span class="tag" style="background:'+(DOMAIN_COLOR[n.domain]||'#555')+'">'+n.domain+'</span>';
h+='<h3>'+esc(n.label.replace('\n',' &middot; '))+'</h3>';
var ex=n.extra||{};
Object.keys(ex).forEach(function(k){ if(ex[k]) h+='<div class="kv"><b>'+k+':</b> '+esc(ex[k])+'</div>';});
if(out.length) h+='<h4>Outgoing ('+out.length+')</h4><ul><li>'+out.join('</li><li>')+'</li></ul>';
if(inc.length) h+='<h4>Incoming ('+inc.length+')</h4><ul><li>'+inc.join('</li><li>')+'</li></ul>';
document.getElementById('ic').innerHTML=h;
document.getElementById('info').style.display='block';
}
// ---- view transforms ----
function applyView(){vp.setAttribute('transform','translate('+view.x+','+view.y+') scale('+view.scale+')');}
function fit(){
var xs=[],ys=[]; for(var id in pos){xs.push(pos[id].x);ys.push(pos[id].y);}
if(!xs.length)return;
var mnx=Math.min.apply(null,xs)-60,mxx=Math.max.apply(null,xs)+60,mny=Math.min.apply(null,ys)-40,mxy=Math.max.apply(null,ys)+40;
var rect=svg.getBoundingClientRect(),cw=mxx-mnx,ch=mxy-mny;
view.scale=Math.min(rect.width/cw,rect.height/ch)*.9; view.scale=Math.min(view.scale,1.4);
view.x=rect.width/2-(mnx+cw/2)*view.scale; view.y=rect.height/2-(mny+ch/2)*view.scale; applyView();
}
function resetView(){fit();}
function zoom(f){var rect=svg.getBoundingClientRect(),cx=rect.width/2,cy=rect.height/2,ns=Math.max(.15,Math.min(view.scale*f,3));view.x=cx-(cx-view.x)*(ns/view.scale);view.y=cy-(cy-view.y)*(ns/view.scale);view.scale=ns;applyView();}
function setLayout(m){layout=m;pos={};if(m==='force')forceLayout();else layerLayout();render();fit();
document.getElementById('b-force').classList.toggle('active',m==='force');
document.getElementById('b-layer').classList.toggle('active',m==='layer');}
// ---- build side panels ----
function buildLegend(){
var tl=document.getElementById('type-legend');
TYPES.forEach(function(t){
var row=document.createElement('div');row.className='legend-row';
row.innerHTML='<span class="legend-dot" style="background:'+TYPE_COLOR[t]+'"></span><span>'+t+'</span>';
row.onclick=function(){typeOn[t]=!typeOn[t];row.classList.toggle('off',!typeOn[t]);render();};
tl.appendChild(row);
});
var rf=document.getElementById('rel-filters');
RELS.forEach(function(r){
var row=document.createElement('div');row.className='filter-row';
row.innerHTML='<label style="display:flex;align-items:center;gap:6px;"><span style="width:12px;height:3px;background:'+(REL_COLOR[r]||'#999')+';display:inline-block;"></span>'+r+'</label>';
var cb=document.createElement('input');cb.type='checkbox';cb.checked=true;
cb.onchange=function(){relOn[r]=cb.checked;render();};
row.appendChild(cb); rf.appendChild(row);
});
}
document.querySelectorAll('input[name=cmode]').forEach(function(rb){rb.onchange=function(){colorMode=rb.value;render();};});
// ---- pan / zoom interactions ----
var drag=false,ds=null,vs=null,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);applyView();});
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;var rect=svg.getBoundingClientRect(),mx=e.clientX-rect.left,my=e.clientY-rect.top,ns=Math.max(.15,Math.min(view.scale*f,3));view.x=mx-(mx-view.x)*(ns/view.scale);view.y=my-(my-view.y)*(ns/view.scale);view.scale=ns;applyView();},{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.scale};}},{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);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,nd=Math.sqrt(dx*dx+dy*dy),ns=Math.max(.15,Math.min(pinch.s*(nd/pinch.d),3)),rect=svg.getBoundingClientRect(),cx=rect.width/2,cy=rect.height/2;view.x=cx-(cx-view.x)*(ns/view.scale);view.y=cy-(cy-view.y)*(ns/view.scale);view.scale=ns;applyView();e.preventDefault();}},{passive:false});
svg.addEventListener('touchend',function(){drag=false;pinch=null;});
buildLegend();
window.addEventListener('load',function(){setLayout('force');});
if(document.readyState==='complete')setLayout('force');
window.addEventListener('resize',fit);
</script>
</body>
</html>