okf 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3ba14268a02a3bc64e391bd522723c79b1dd9b8c146f341bb57b74397abbd0ea
4
- data.tar.gz: 85b2f13ab45e83bb894cd681c36b0cbf4db161484b9c7de28fd186572e4c633e
3
+ metadata.gz: 3df6877dab7b0fa0bd7353d288b9bbec2657056744ad9d5513c1817921659875
4
+ data.tar.gz: 68f3b956056874fac093ec3114eea5ba420173727458ae1d97c562c3c22ff28c
5
5
  SHA512:
6
- metadata.gz: 9f549361de5213dc0ef48506ffc62e3e668631a38c4666f82e005fd6f6be4f9e5e667e6daa995c0db86785d6f17d9f2c557d97e04ee75e7c1b9d8ad3cab69f47
7
- data.tar.gz: 2c39cd025790fea098fab4e30a9ebcaf3bc48dc797b4336253f967142f47edab339985b034769259380e366d10882d15fef611a8796bb057d43f87b96fc0b7ff
6
+ metadata.gz: 2e9da0cf2f912c2473302266307e0180ac038c97c75636f06299e7b25adeb64cb38220f755bf4101bee6edbdb52ae9a4cb696b2b14f2c8b54dd28ce3225d4b5c
7
+ data.tar.gz: 0eb35fde3097549a55fde740816178d830628ade3639a356e1e2eea5e195ee3ea28b321ccb4bd9211284103a90c5ab33830fb1218a298034788d258a06cf5dab
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.0] - 2026-07-12
4
+
5
+ - Graph server UX round. Selecting a node now makes one camera move instead of
6
+ two (the pan used to race the opening panel and the debounced canvas resize,
7
+ a dizzying double movement; rapid clicks also queued animations — both fixed).
8
+ Relative markdown links inside the inspector and the files preview resolve
9
+ against the open concept and navigate in-app — clicking `../model/graph.md`
10
+ selects that concept instead of 404ing the page; external links open in a new
11
+ tab; links that leave the bundle are disabled, never a 404. Nodes are smaller
12
+ (14–44px, was 24–70) and layouts keep a real gap between them (`nodeOverlap`
13
+ for cose, `avoidOverlap`/`spacingFactor` elsewhere). The inspector and the
14
+ files list are drag-resizable (persisted, double-click resets), and the files
15
+ reader now uses the full pane width. New file-tree mode on the graph toolbar:
16
+ folders become nodes and the only edges are folder→child, an acyclic layered
17
+ tree of the bundle's files. On small screens (≤900px) the inspector starts
18
+ hidden and opens on the first node tap; camera moves are gentler (450ms,
19
+ ease-in-out).
20
+
3
21
  ## [1.3.0] - 2026-07-12
4
22
 
5
23
  - The graph server page now emits link-preview metadata: Open Graph and Twitter
@@ -136,14 +136,20 @@
136
136
 
137
137
  /* ── graph view ── */
138
138
  .graph-body{display:grid;grid-template-columns:1fr var(--side-w);height:100%;min-height:0;
139
- --side-w:380px;transition:--side-w .22s cubic-bezier(.22,1,.36,1)}
139
+ --side-w:var(--side-w-user,380px);transition:--side-w .22s cubic-bezier(.22,1,.36,1)}
140
140
  .graph-body[data-side=hidden]{--side-w:0px}
141
141
  .graph-body[data-side=wide]{--side-w:70vw}
142
+ .graph-body.side-drag{transition:none}
142
143
  #stage{position:relative;min-width:0;overflow:hidden;background:var(--canvas)}
143
144
  #cy{position:absolute;inset:0}
144
145
  .ghint{position:absolute;right:16px;bottom:14px;z-index:3;font-size:11px;color:var(--faint);pointer-events:none}
145
- #side{min-width:0;background:var(--panel);border-left:1px solid var(--line);display:flex;flex-direction:column;overflow:hidden}
146
+ #side{position:relative;min-width:0;background:var(--panel);border-left:1px solid var(--line);display:flex;flex-direction:column;overflow:hidden}
146
147
  .graph-body[data-side=hidden] #side{border-left:0}
148
+ /* drag handle on the panel's left edge (double-click resets the width) */
149
+ #side-resizer{position:absolute;left:0;top:0;bottom:0;width:7px;cursor:col-resize;z-index:4;touch-action:none}
150
+ #side-resizer::after{content:"";position:absolute;left:0;top:0;bottom:0;width:2px;background:var(--accent);opacity:0;transition:opacity .15s}
151
+ #side-resizer:hover::after,.graph-body.side-drag #side-resizer::after{opacity:.7}
152
+ .graph-body[data-side=hidden] #side-resizer{display:none}
147
153
  #side-head{display:flex;align-items:center;gap:6px;padding:9px 12px;border-bottom:1px solid var(--line);flex:none}
148
154
  #side-head .lbl{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--faint);margin-right:auto}
149
155
  #side-head .btn{width:30px;height:30px}
@@ -183,6 +189,7 @@
183
189
  .body>*:first-child{margin-top:0}
184
190
  .body h1{font-size:19px}.body h2{font-size:16px}.body h3{font-size:14px}.body h1,.body h2,.body h3{line-height:1.3;font-weight:600;margin:1.2em 0 .5em}
185
191
  .body a{color:var(--accent-ink)}
192
+ #side-body a.dead,.fp-body a.dead{opacity:.55;cursor:not-allowed;text-decoration:line-through}
186
193
  .body code{background:var(--line-2);border:1px solid var(--line);padding:1px 5px;border-radius:5px;font-size:.85em;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
187
194
  .body pre{background:var(--line-2);border:1px solid var(--line);padding:12px;border-radius:10px;overflow:auto}
188
195
  .body pre code{background:none;border:0;padding:0}
@@ -211,8 +218,12 @@
211
218
  .none{grid-column:1/-1;text-align:center;color:var(--faint);padding:48px;border:1px dashed var(--line);border-radius:12px}
212
219
 
213
220
  /* ── files ── */
214
- .files-grid{display:grid;grid-template-columns:320px 1fr;height:100%;min-height:0}
215
- .ftree{border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;background:var(--panel)}
221
+ .files-grid{display:grid;grid-template-columns:var(--ftree-w,320px) 1fr;height:100%;min-height:0}
222
+ .ftree{position:relative;border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;background:var(--panel)}
223
+ /* drag handle on the file list's right edge (double-click resets the width) */
224
+ #ftree-resizer{position:absolute;right:-4px;top:0;bottom:0;width:7px;cursor:col-resize;z-index:4;touch-action:none}
225
+ #ftree-resizer::after{content:"";position:absolute;right:3px;top:0;bottom:0;width:2px;background:var(--accent);opacity:0;transition:opacity .15s}
226
+ #ftree-resizer:hover::after,#ftree-resizer.dragging::after{opacity:.7}
216
227
  .ftree-head{padding:12px;border-bottom:1px solid var(--line);flex:none}
217
228
  .ftree-list{overflow:auto;flex:1;padding:8px}
218
229
  .ffolder{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--faint);padding:12px 8px 5px;font-weight:600}
@@ -224,7 +235,7 @@
224
235
  .fpreview{min-width:0;overflow:auto;display:flex;flex-direction:column}
225
236
  .fp-head{display:flex;align-items:center;gap:10px;padding:14px 22px;border-bottom:1px solid var(--line);flex:none}
226
237
  .fp-head .title{margin:0;font-size:16px}
227
- .fp-body{padding:20px 24px;overflow:auto;flex:1;max-width:820px}
238
+ .fp-body{padding:20px 28px;overflow:auto;flex:1}
228
239
  .fp-body .body{border-top:0;padding-top:0;margin-top:0}
229
240
 
230
241
  /* ── tags ── */
@@ -314,6 +325,8 @@
314
325
  <svg viewBox="0 0 24 24"><path d="M3 9V6a2 2 0 0 1 2-2h3M21 9V6a2 2 0 0 0-2-2h-3M3 15v3a2 2 0 0 0 2 2h3M21 15v3a2 2 0 0 0-2 2h-3"/><rect x="9.5" y="9.5" width="5" height="5" rx="1"/></svg></button>
315
326
  <button class="btn" id="btn-cluster" type="button" aria-label="Cluster by area" aria-pressed="false" title="Cluster nodes by area">
316
327
  <svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="4" opacity=".45"/><circle cx="9" cy="9" r="1.9" fill="currentColor" stroke="none"/><circle cx="15" cy="10" r="1.9" fill="currentColor" stroke="none"/><circle cx="11" cy="15" r="1.9" fill="currentColor" stroke="none"/></svg></button>
328
+ <button class="btn" id="btn-tree" type="button" aria-label="File tree" aria-pressed="false" title="File tree: folders and files, parent-to-child edges only">
329
+ <svg viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="5" rx="1.2"/><rect x="3" y="16" width="6" height="5" rx="1.2"/><rect x="15" y="16" width="6" height="5" rx="1.2"/><path d="M12 8v4M12 12H6v4M12 12h6v4"/></svg></button>
317
330
  <button class="btn" id="btn-panel" type="button" aria-label="Toggle inspector" aria-pressed="true"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M15 4v16"/></svg></button>
318
331
  </div>
319
332
  </header>
@@ -338,6 +351,7 @@
338
351
  </aside>
339
352
  </div>
340
353
  <aside id="side">
354
+ <div id="side-resizer" title="Drag to resize · double-click to reset" aria-hidden="true"></div>
341
355
  <div id="side-head"><span class="lbl">Inspector</span>
342
356
  <button class="btn" id="side-widen" type="button" aria-label="Widen panel" aria-pressed="false" title="Widen / restore">
343
357
  <svg class="wide" viewBox="0 0 24 24"><path d="M13 6l-6 6 6 6M19 6l-6 6 6 6"/></svg>
@@ -380,6 +394,7 @@
380
394
  <div class="combo" id="file-type-combo"><input id="file-type-input" class="field" placeholder="all types" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="file-type-list" aria-autocomplete="list" aria-label="Filter files by type"><button class="clear" id="file-type-clear" type="button" aria-label="Clear type filter"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button><ul class="listbox" id="file-type-list" role="listbox" hidden></ul></div>
381
395
  <div class="combo" id="file-tag-combo"><input id="file-tag-input" class="field" placeholder="all tags" autocomplete="off" role="combobox" aria-expanded="false" aria-controls="file-tag-list" aria-autocomplete="list" aria-label="Filter files by tag"><button class="clear" id="file-tag-clear" type="button" aria-label="Clear tag filter"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button><ul class="listbox" id="file-tag-list" role="listbox" hidden></ul></div></div>
382
396
  <div class="ftree-list" id="ftree-list"><p class="empty" style="padding:14px">Loading files…</p></div>
397
+ <div id="ftree-resizer" title="Drag to resize · double-click to reset" aria-hidden="true"></div>
383
398
  </div>
384
399
  <div class="fpreview">
385
400
  <div class="fp-head" id="fp-head" hidden><span class="type" id="fp-type"></span><h2 class="title" id="fp-title"></h2>
@@ -454,11 +469,19 @@ function styleSheet(){const ink=cvar('--ink'),halo=cvar('--canvas'),edge=cvar('-
454
469
  {selector:'edge',style:{'width':1.2,'line-color':edge,'target-arrow-color':edge,'target-arrow-shape':'triangle','arrow-scale':.8,'curve-style':'bezier','opacity':.8}},
455
470
  {selector:'.dim',style:{'opacity':.1}},
456
471
  {selector:'.hl',style:{'border-width':3,'border-color':cvar('--accent')}},
472
+ {selector:'node.dir',style:{'shape':'round-rectangle','background-color':cvar('--faint'),'background-opacity':.85,
473
+ 'font-size':9.5,'font-weight':600,'color':cvar('--muted')}},
474
+ {selector:'edge.tree',style:{'width':1.1,'opacity':.7,'target-arrow-shape':'none'}},
475
+ {selector:'edge.linkhid',style:{'display':'none'}},
457
476
  {selector:':parent',style:{'background-color':cvar('--ink'),'background-opacity':0.05,'border-width':1,'border-color':cvar('--edge'),'shape':'round-rectangle','padding':26,'label':'data(label)','color':cvar('--ink'),'font-size':13,'font-weight':600,'text-valign':'top','text-halign':'center','text-margin-y':-8,'min-zoomed-font-size':0,'text-outline-width':3,'text-outline-color':cvar('--canvas')}}
458
477
  ];}
478
+ /* Small screens start with the inspector hidden — it opens on the first node
479
+ tap (openPanel). Done before Cytoscape reads the container, so the graph
480
+ lays out at full width; larger screens keep the open panel as the hint. */
481
+ if(matchMedia('(max-width: 900px)').matches)document.querySelector('.graph-body').setAttribute('data-side','hidden');
459
482
  const cy=cytoscape({container:document.getElementById('cy'),minZoom:.2,maxZoom:1.6,wheelSensitivity:.2,
460
483
  elements:[...NODES.map(n=>({data:{id:n.id,title:n.title,c:color[typeOf[n.id]||'Untyped']||'#64748b',sz:szOf(n.id)}})),...EDGES.map(e=>({data:e}))],
461
- style:styleSheet(),layout:{name:'<%= @layout %>',animate:false,nodeRepulsion:9000,idealEdgeLength:90,padding:40}});
484
+ style:styleSheet(),layout:{name:'<%= @layout %>',animate:false,nodeRepulsion:9000,idealEdgeLength:90,padding:40,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2}});
462
485
  function applyCyTheme(){cy.style(styleSheet());}
463
486
  /* Debounced so the panel width transition (--side-w, .22s) fires cy.resize()
464
487
  once after it settles instead of on every frame — avoids canvas flicker. */
@@ -490,13 +513,67 @@ function setSide(s){gbody.setAttribute('data-side',s);btnPanel.setAttribute('ari
490
513
  sideWiden.setAttribute('aria-pressed',String(s==='wide'));
491
514
  sideWiden.querySelector('.wide').style.display=s==='wide'?'none':'block';
492
515
  sideWiden.querySelector('.narrow').style.display=s==='wide'?'block':'none';}
493
- function openPanel(){if(gbody.getAttribute('data-side')==='hidden')setSide('default');}
516
+ function openPanel(){if(gbody.getAttribute('data-side')!=='hidden')return false;setSide('default');return true;}
494
517
  btnPanel.onclick=()=>setSide(gbody.getAttribute('data-side')==='hidden'?'default':'hidden');
495
518
  document.getElementById('side-close').onclick=()=>setSide('hidden');
496
519
  sideWiden.onclick=()=>setSide(gbody.getAttribute('data-side')==='wide'?'default':'wide');
520
+ /* sync the header buttons with the responsive initial state set before cy init */
521
+ if(gbody.getAttribute('data-side')==='hidden')setSide('hidden');
522
+ /* ── drag-resize splitters: inspector + files tree (persisted; dblclick resets) ── */
523
+ function splitter(handle,host,cssVar,key,widthAt,opts){opts=opts||{};
524
+ /* restore clamped to the current viewport — a width dragged on a desktop
525
+ must not swallow a phone screen */
526
+ try{const w=parseInt(localStorage.getItem(key),10);
527
+ if(w)host.style.setProperty(cssVar,Math.min(w,Math.round(innerWidth*.7))+'px');}catch(e){}
528
+ handle.addEventListener('pointerdown',e=>{e.preventDefault();
529
+ if(opts.onStart)opts.onStart();
530
+ try{handle.setPointerCapture(e.pointerId);}catch(err){}
531
+ handle.classList.add('dragging');host.classList.add('side-drag');
532
+ let lastW=0,raf=false;
533
+ /* Track the drag on window so it keeps working even where pointer capture
534
+ is unavailable — the pointer leaves the 7px handle on the first frame. */
535
+ const move=ev=>{lastW=Math.round(widthAt(ev));host.style.setProperty(cssVar,lastW+'px');
536
+ if(opts.onMove&&!raf){raf=true;requestAnimationFrame(()=>{raf=false;opts.onMove();});}};
537
+ const up=()=>{removeEventListener('pointermove',move);removeEventListener('pointerup',up);removeEventListener('pointercancel',up);
538
+ handle.classList.remove('dragging');host.classList.remove('side-drag');
539
+ if(opts.onEnd)opts.onEnd();
540
+ if(lastW){try{localStorage.setItem(key,String(lastW));}catch(e){}}};
541
+ addEventListener('pointermove',move);addEventListener('pointerup',up);addEventListener('pointercancel',up);});
542
+ handle.addEventListener('dblclick',()=>{host.style.removeProperty(cssVar);
543
+ try{localStorage.removeItem(key);}catch(e){}
544
+ if(opts.onEnd)opts.onEnd();});}
545
+ const clampW=(v,lo,hi)=>Math.min(Math.max(v,lo),hi);
546
+ splitter(document.getElementById('side-resizer'),gbody,'--side-w-user','okf-side-w',
547
+ ev=>{const r=gbody.getBoundingClientRect();return clampW(r.right-ev.clientX,280,r.width*.8);},
548
+ {onStart:()=>{if(gbody.getAttribute('data-side')==='wide')setSide('default');},
549
+ onMove:()=>cy.resize(),onEnd:()=>cy.resize()});
550
+ const fgrid=document.querySelector('.files-grid');
551
+ splitter(document.getElementById('ftree-resizer'),fgrid,'--ftree-w','okf-ftree-w',
552
+ ev=>{const r=fgrid.getBoundingClientRect();return clampW(ev.clientX-r.left,220,r.width*.55);});
497
553
  function relList(t,arr){if(!arr.length)return'';return `<div class="rel"><h4>${t}</h4>${arr.map(id=>`<a data-go="${esc(id)}">${esc((byId[id]||{}).title||id)}</a>`).join('')}</div>`;}
498
- function show(id){const n=byId[id];if(!n)return;const ty=typeOf[id]||'Untyped';const c=color[ty]||'#64748b';
499
- openPanel();sideBody.scrollTop=0;
554
+ /* Markdown links inside the inspector are GitHub-style relative file paths;
555
+ there is no route for them here, so a plain click 404s the whole page.
556
+ Resolve them against the open concept and navigate in-app instead; external
557
+ links open in a new tab; anything unresolvable is disabled, never a 404. */
558
+ let shownId=null;
559
+ function resolveConcept(href,baseId){let p=href.split('#')[0].split('?')[0];if(!p)return null;
560
+ try{p=decodeURIComponent(p);}catch(e){}
561
+ const out=p.charAt(0)==='/'?[]:(baseId?baseId.split('/').slice(0,-1):[]);
562
+ for(const s of p.replace(/^\//,'').split('/')){if(!s||s==='.')continue;if(s==='..'){out.pop();continue;}out.push(s);}
563
+ const id=out.join('/').replace(/\.md$/i,'');
564
+ return byId[id]?id:null;}
565
+ function interceptMdLinks(container,baseIdOf,go){container.addEventListener('click',e=>{
566
+ const a=e.target.closest('a');
567
+ if(!a||!container.contains(a)||a.hasAttribute('data-go'))return;
568
+ const href=a.getAttribute('href')||'';
569
+ if(/^[a-z][a-z0-9+.-]*:/i.test(href)||href.indexOf('//')===0){e.preventDefault();window.open(a.href,'_blank','noopener');return;}
570
+ e.preventDefault();if(href.charAt(0)==='#')return;
571
+ const id=resolveConcept(href,baseIdOf());
572
+ if(id)go(id);else{a.classList.add('dead');a.title='not a concept in this bundle';}});}
573
+ interceptMdLinks(sideBody,()=>shownId,select);
574
+ interceptMdLinks(document.getElementById('fp-body'),()=>fileSel,id=>openFile(id));
575
+ function show(id){const n=byId[id];if(!n)return false;const ty=typeOf[id]||'Untyped';const c=color[ty]||'#64748b';
576
+ shownId=id;const opened=openPanel();sideBody.scrollTop=0;
500
577
  sideBody.innerHTML=`<span class="type"><span class="dot" style="background:${c}"></span>${esc(ty)}</span>
501
578
  <h2 class="title">${esc(n.title||id)}</h2><div class="desc loading" id="desc">loading…</div>
502
579
  <div class="tags">${(tagsOf[id]||[]).map(t=>`<span class="tag">${esc(t)}</span>`).join('')}</div>
@@ -505,12 +582,22 @@ function show(id){const n=byId[id];if(!n)return;const ty=typeOf[id]||'Untyped';c
505
582
  const eid=encodeURIComponent(id);
506
583
  fetch(META_ENDPOINT+'?id='+eid).then(r=>r.ok?r.text():'').then(h=>{const d=document.getElementById('desc');if(d){d.innerHTML=h||'<span class="empty">no description</span>';d.classList.remove('loading');}}).catch(()=>{});
507
584
  const bodyEl=sideBody.querySelector('#body');
508
- fetch(NODE_ENDPOINT+'?id='+eid).then(r=>r.ok?r.text():'').then(md=>{if(bodyEl.isConnected)renderMarkdown(bodyEl,md);}).catch(()=>{});}
509
- function select(id){const ele=cy.getElementById(id);if(!ele.length)return;show(id);
585
+ fetch(NODE_ENDPOINT+'?id='+eid).then(r=>r.ok?r.text():'').then(md=>{if(bodyEl.isConnected)renderMarkdown(bodyEl,md);}).catch(()=>{});
586
+ return opened;}
587
+ /* One camera move per selection. When the click also opens the panel, the
588
+ container is still animating (--side-w, .22s), so pan now and the debounced
589
+ cy.resize() would shift the view again a moment later — the double movement.
590
+ Instead wait for the panel to settle, resize once, then pan. cy.stop() first
591
+ so rapid clicks replace the animation instead of queueing repeats. */
592
+ let centerTimer;
593
+ function centerOn(ele,delay){clearTimeout(centerTimer);
594
+ const go=()=>{cy.resize();cy.stop();cy.animate({center:{eles:ele},duration:450,easing:'ease-in-out-cubic'});};
595
+ if(delay)centerTimer=setTimeout(go,260);else{cy.stop();cy.animate({center:{eles:ele},duration:450,easing:'ease-in-out-cubic'});}}
596
+ function select(id){const ele=cy.getElementById(id);if(!ele.length)return;const opened=show(id);
510
597
  cy.elements().removeClass('hl').addClass('dim');const nb=ele.closedNeighborhood();nb.removeClass('dim');ele.addClass('hl');
511
- cy.animate({center:{eles:ele},duration:250});
598
+ centerOn(ele,opened);
512
599
  try{if(decodeURIComponent((location.hash||'').slice(1))!==id)location.hash=encodeURIComponent(id);}catch(e){}}
513
- cy.on('tap','node',e=>{if(e.target.isParent())return;select(e.target.id());});
600
+ cy.on('tap','node',e=>{if(e.target.isParent()||e.target.hasClass('dir'))return;select(e.target.id());});
514
601
  cy.on('tap',e=>{if(e.target===cy)cy.elements().removeClass('dim hl');});
515
602
 
516
603
  /* ── graph filters (slide-over) ── */
@@ -518,7 +605,7 @@ const hiddenTypes=new Set(), activeTags=new Set(), activeAreas=new Set();
518
605
  const searchInput=document.getElementById('search');
519
606
  const q={graph:'',catalog:'',files:'',tags:''};
520
607
  function applyGraphFilter(){const s=q.graph.toLowerCase();
521
- cy.nodes().forEach(n=>{if(n.isParent())return;const id=n.data('id');const t=typeOf[id]||'Untyped';const nt=tagsOf[id]||[];
608
+ cy.nodes().forEach(n=>{if(n.isParent()||n.hasClass('dir'))return;const id=n.data('id');const t=typeOf[id]||'Untyped';const nt=tagsOf[id]||[];
522
609
  const hay=(n.data('title')+' '+t+' '+nt.join(' ')).toLowerCase();
523
610
  const m=(!s||hay.includes(s))&&!hiddenTypes.has(t)&&(activeTags.size===0||nt.some(x=>activeTags.has(x)))&&(activeAreas.size===0||activeAreas.has(areaOf(id)));
524
611
  n.style('display',m?'element':'none');});}
@@ -570,7 +657,10 @@ function loadScript(src){return new Promise((res,rej)=>{const s=document.createE
570
657
  async function ensureLayout(name){const ext=LAYOUT_EXT[name];if(!ext||loadedExt[name])return true;
571
658
  try{for(const src of ext.srcs)await loadScript(src);cytoscape.use(window[ext.reg]);loadedExt[name]=true;return true;}
572
659
  catch(e){console.warn('layout '+name+' failed to load',e);return false;}}
573
- function layoutOpts(name){const base={animate:true,padding:40};
660
+ /* nodeOverlap keeps a real gap between circles in cose; avoidOverlap +
661
+ spacingFactor do the same for the geometric layouts. Extras are ignored
662
+ by layouts that don't know them. */
663
+ function layoutOpts(name){const base={animate:true,padding:40,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2};
574
664
  if(name==='fcose')return{...base,name:'fcose',quality:'default',nodeRepulsion:6500,idealEdgeLength:95,nodeSeparation:80,packComponents:true};
575
665
  if(name==='dagre')return{...base,name:'dagre',rankDir:'TB',nodeSep:26,rankSep:55,edgeSep:12};
576
666
  if(name==='cola')return{...base,name:'cola',maxSimulationTime:2500,edgeLength:95,nodeSpacing:8};
@@ -582,24 +672,53 @@ layoutSel.value='<%= @layout %>';
582
672
 
583
673
  /* ── cluster by area (compound parent nodes) ── */
584
674
  let clustered=false;const btnCluster=document.getElementById('btn-cluster');
585
- function setClustered(on){if(on===clustered)return;clustered=on;btnCluster.setAttribute('aria-pressed',String(on));layoutSel.disabled=on;
675
+ function setClustered(on){if(on===clustered)return;if(on&&treeMode)setTree(false);
676
+ clustered=on;btnCluster.setAttribute('aria-pressed',String(on));layoutSel.disabled=on;
586
677
  if(on){const as=[...new Set(NODES.map(n=>areaOf(n.id)))];
587
678
  cy.add(as.map(a=>({group:'nodes',data:{id:'area::'+a,label:a}})));
588
679
  cy.batch(()=>NODES.forEach(n=>{const e=cy.getElementById(n.id);if(e.length)e.move({parent:'area::'+areaOf(n.id)});}));
589
- cy.layout({name:'cose',animate:true,padding:40,nodeRepulsion:8000,idealEdgeLength:70,nestingFactor:1.2,gravity:.7}).run();
680
+ cy.layout({name:'cose',animate:true,padding:40,nodeRepulsion:8000,idealEdgeLength:70,nodeOverlap:24,nestingFactor:1.2,gravity:.7}).run();
590
681
  }else{
591
682
  cy.batch(()=>NODES.forEach(n=>{const e=cy.getElementById(n.id);if(e.length&&e.isChild())e.move({parent:null});}));
592
683
  cy.nodes().filter(n=>n.id().indexOf('area::')===0).remove();
593
- cy.layout({name:layoutSel.value,animate:true,padding:40,nodeRepulsion:9000,idealEdgeLength:90}).run();}}
684
+ cy.layout({name:layoutSel.value,animate:true,padding:40,nodeRepulsion:9000,idealEdgeLength:90,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2}).run();}}
594
685
  btnCluster.onclick=()=>setClustered(!clustered);
595
686
 
687
+ /* ── file-tree mode: folders become nodes and the only edges are folder→child,
688
+ so the picture is the acyclic files-and-folders tree, not the link graph. ── */
689
+ let treeMode=false;const btnTree=document.getElementById('btn-tree');
690
+ const DIR='dir::';
691
+ function setTree(on){if(on===treeMode)return;if(on&&clustered)setClustered(false);
692
+ treeMode=on;btnTree.setAttribute('aria-pressed',String(on));layoutSel.disabled=on;btnCluster.disabled=on;
693
+ if(on){
694
+ const dirs=new Set(['']);
695
+ NODES.forEach(n=>{const parts=n.id.split('/');for(let i=1;i<parts.length;i++)dirs.add(parts.slice(0,i).join('/'));});
696
+ cy.batch(()=>{
697
+ cy.add([...dirs].map(d=>({group:'nodes',classes:'dir',
698
+ data:{id:DIR+d,title:d?d.split('/').pop()+'/':'/',c:cvar('--faint'),sz:d?20:28}})));
699
+ [...dirs].forEach(d=>{if(!d)return;const p=d.split('/').slice(0,-1).join('/');
700
+ cy.add({group:'edges',classes:'tree',data:{id:'tree::'+d,source:DIR+p,target:DIR+d}});});
701
+ NODES.forEach(n=>{const p=n.id.split('/').slice(0,-1).join('/');
702
+ cy.add({group:'edges',classes:'tree',data:{id:'tree::n::'+n.id,source:DIR+p,target:n.id}});});
703
+ cy.edges().not('.tree').addClass('linkhid');
704
+ cy.elements().removeClass('dim hl');});
705
+ /* Layered tree: root on top, folders as branches, files as leaves.
706
+ animate:false — the freshly added folder nodes have no positions yet, and
707
+ an animated run from (0,0) is unreliable; cut to the tree and fit. */
708
+ cy.layout({name:'breadthfirst',directed:true,roots:cy.getElementById(DIR),animate:false,padding:40,spacingFactor:1.6,avoidOverlap:true}).run();
709
+ fitGraph();
710
+ }else{
711
+ cy.batch(()=>{cy.edges('.tree').remove();cy.nodes('.dir').remove();cy.edges().removeClass('linkhid');});
712
+ runLayout(layoutSel.value);}}
713
+ btnTree.onclick=()=>setTree(!treeMode);
714
+
596
715
  /* ── fit to screen ── */
597
716
  // cy.animate({fit:…}) is a no-op in this Cytoscape build, so compute the target
598
717
  // zoom + pan and animate those (which do work): fits the visible nodes, re-centered.
599
718
  function fitGraph(){const vis=cy.nodes(':visible').filter(n=>!n.isParent());const eles=vis.length?vis:cy.nodes();if(!eles.length)return;
600
719
  const bb=eles.boundingBox(),pad=60,w=cy.width(),h=cy.height();
601
720
  const z=Math.max(cy.minZoom(),Math.min(cy.maxZoom(),Math.min((w-2*pad)/bb.w,(h-2*pad)/bb.h)));
602
- cy.stop();cy.animate({zoom:z,pan:{x:w/2-z*(bb.x1+bb.w/2),y:h/2-z*(bb.y1+bb.h/2)}},{duration:300,easing:'ease-out-cubic'});}
721
+ cy.stop();cy.animate({zoom:z,pan:{x:w/2-z*(bb.x1+bb.w/2),y:h/2-z*(bb.y1+bb.h/2)}},{duration:450,easing:'ease-in-out-cubic'});}
603
722
  document.getElementById('btn-fit').onclick=fitGraph;
604
723
 
605
724
  /* ── view switching ── */
@@ -21,8 +21,8 @@ module OKF
21
21
  LAYOUTS = %w[cose concentric breadthfirst circle grid].freeze
22
22
 
23
23
  # Node-diameter range in px; the template scales within it by node degree.
24
- MIN_SIZE = 24
25
- MAX_SIZE = 70
24
+ MIN_SIZE = 14
25
+ MAX_SIZE = 44
26
26
 
27
27
  # The 6-character JSON unicode escape for `<` (backslash, u, 0, 0, 3, c),
28
28
  # built from the backslash code point so no literal escape appears here.
data/lib/okf/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OKF
4
- VERSION = "1.3.0"
4
+ VERSION = "1.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: okf
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rodrigo Serradura