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 +4 -4
- data/CHANGELOG.md +18 -0
- data/lib/okf/server/graph/template.html.erb +138 -19
- data/lib/okf/server/graph.rb +2 -2
- data/lib/okf/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3df6877dab7b0fa0bd7353d288b9bbec2657056744ad9d5513c1817921659875
|
|
4
|
+
data.tar.gz: 68f3b956056874fac093ec3114eea5ba420173727458ae1d97c562c3c22ff28c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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')
|
|
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
|
-
|
|
499
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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:
|
|
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 ── */
|
data/lib/okf/server/graph.rb
CHANGED
|
@@ -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 =
|
|
25
|
-
MAX_SIZE =
|
|
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