okf 1.10.0 → 1.11.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 +203 -0
- data/README.md +238 -413
- data/lib/okf/bundle/folder.rb +20 -5
- data/lib/okf/bundle/linter.rb +1 -1
- data/lib/okf/bundle/search/index.rb +13 -3
- data/lib/okf/bundle/search.rb +91 -11
- data/lib/okf/bundle.rb +1 -1
- data/lib/okf/cli/catalog.rb +1 -1
- data/lib/okf/cli/command.rb +169 -7
- data/lib/okf/cli/dirs.rb +118 -0
- data/lib/okf/cli/files.rb +2 -2
- data/lib/okf/cli/index.rb +67 -25
- data/lib/okf/cli/loose.rb +2 -2
- data/lib/okf/cli/search.rb +1 -1
- data/lib/okf/cli/server.rb +8 -1
- data/lib/okf/cli/stats.rb +33 -8
- data/lib/okf/cli/tags.rb +29 -7
- data/lib/okf/cli/types.rb +1 -1
- data/lib/okf/cli.rb +9 -5
- data/lib/okf/render/graph/template.html.erb +293 -96
- data/lib/okf/server/app.rb +61 -0
- data/lib/okf/server/hub.rb +35 -27
- data/lib/okf/skill/SKILL.md +12 -9
- data/lib/okf/skill/playbooks/consume.md +3 -3
- data/lib/okf/skill/playbooks/maintain.md +4 -3
- data/lib/okf/skill/playbooks/menu.md +1 -1
- data/lib/okf/skill/playbooks/refine.md +4 -3
- data/lib/okf/skill/playbooks/search.md +7 -7
- data/lib/okf/skill/reference/cli.md +100 -18
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +9 -0
- metadata +20 -8
|
@@ -120,6 +120,15 @@
|
|
|
120
120
|
font:inherit;font-size:13px;padding:0 10px;outline:none;transition:border-color .15s,box-shadow .15s}
|
|
121
121
|
.field:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
|
122
122
|
.field::placeholder{color:var(--muted)}
|
|
123
|
+
/* iOS Safari zooms the page whenever a focused control is under 16px, and it
|
|
124
|
+
never zooms back out — so every `/` left the reader pinching. 16px is the
|
|
125
|
+
whole fix; `maximum-scale=1` would also stop it and is the wrong trade, since
|
|
126
|
+
it takes pinch-zoom away from everyone to spare one gesture.
|
|
127
|
+
Both conditions, because neither covers the other: a phone is narrow, and a
|
|
128
|
+
tablet in landscape is not — but it still zooms. Desktop keeps 13px. */
|
|
129
|
+
@media (max-width:768px),(pointer:coarse){
|
|
130
|
+
.field{font-size:16px}
|
|
131
|
+
}
|
|
123
132
|
.search{position:relative;display:flex;align-items:center;flex:1 1 320px;min-width:130px;max-width:520px}
|
|
124
133
|
.search svg{position:absolute;left:11px;width:15px;height:15px;stroke:var(--muted);fill:none;stroke-width:1.8;pointer-events:none}
|
|
125
134
|
.search input{width:100%;padding-left:33px;padding-right:52px}
|
|
@@ -154,6 +163,12 @@
|
|
|
154
163
|
.sb-act kbd{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10px;color:var(--faint);
|
|
155
164
|
border:1px solid var(--line);border-radius:4px;padding:1px 4px;background:var(--panel);line-height:1.3}
|
|
156
165
|
.selwrap{position:relative;display:inline-flex;flex:none}
|
|
166
|
+
/* `display:inline-flex` above outranks the [hidden] attribute's own
|
|
167
|
+
display:none, so the depth select would sit in the bar unclustered. */
|
|
168
|
+
.selwrap[hidden]{display:none}
|
|
169
|
+
/* the depth control is a single digit — sized to its content, not to the
|
|
170
|
+
layout select beside it */
|
|
171
|
+
#cluster-depth{padding-right:26px;min-width:0}
|
|
157
172
|
.selwrap::after{content:"";position:absolute;right:12px;top:50%;width:7px;height:7px;border-right:1.6px solid var(--muted);border-bottom:1.6px solid var(--muted);transform:translateY(-70%) rotate(45deg);pointer-events:none}
|
|
158
173
|
select.field{appearance:none;-webkit-appearance:none;padding-right:30px;cursor:pointer}
|
|
159
174
|
select.field:disabled{opacity:.45;cursor:not-allowed}
|
|
@@ -285,6 +300,7 @@
|
|
|
285
300
|
.ws-pill.def{color:var(--accent-ink);background:var(--accent-soft);border-color:transparent}
|
|
286
301
|
.ws-meta{display:flex;align-items:center;gap:9px;font-size:11.5px;color:var(--muted);margin-top:3px;flex-wrap:wrap}
|
|
287
302
|
.ws-slug{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--faint)}
|
|
303
|
+
.ws-where{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--faint)}
|
|
288
304
|
.ws-health.ok{color:var(--ok)} .ws-health.warn{color:var(--warn)}
|
|
289
305
|
.ws-health.error,.ws-health.missing{color:var(--accent-ink)}
|
|
290
306
|
.ws-slug-edit{height:24px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;padding:0 7px;width:150px;border-radius:6px}
|
|
@@ -405,7 +421,7 @@
|
|
|
405
421
|
#sw-input{width:100%;box-sizing:border-box}
|
|
406
422
|
#sw-list{list-style:none;margin:10px 0 0;padding:0;max-height:46vh;overflow:auto}
|
|
407
423
|
#sw-list a{display:flex;align-items:center;gap:9px;padding:9px 10px;border-radius:8px;color:var(--ink);text-decoration:none;font-size:14px}
|
|
408
|
-
#sw-list a .slug{margin-left:auto;color:var(--faint);font-size:12px}
|
|
424
|
+
#sw-list a .slug,#sw-list a .sw-where{margin-left:auto;color:var(--faint);font-size:12px}
|
|
409
425
|
#sw-list .sw-grp{margin:9px 0 3px;padding:0 10px;font-size:10.5px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--faint)}
|
|
410
426
|
#sw-list .sw-grp:first-child{margin-top:0}
|
|
411
427
|
/* Under a hub a view is the sideline, so it sits at the muted weight until the
|
|
@@ -891,8 +907,9 @@
|
|
|
891
907
|
</select></span>
|
|
892
908
|
<button class="btn" id="btn-fit" type="button" aria-label="Fit graph to screen" title="Fit graph to screen">
|
|
893
909
|
<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>
|
|
894
|
-
<button class="btn" id="btn-cluster" type="button" aria-label="Cluster by
|
|
910
|
+
<button class="btn" id="btn-cluster" type="button" aria-label="Cluster by directory" aria-pressed="false" title="Cluster by directory — each cluster is a directory of concepts">
|
|
895
911
|
<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>
|
|
912
|
+
<span class="selwrap" id="cluster-depth-wrap" hidden><select id="cluster-depth" class="field" aria-label="Cluster depth" title="How many directory levels to nest"></select></span>
|
|
896
913
|
<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">
|
|
897
914
|
<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>
|
|
898
915
|
<button class="btn" id="btn-ix" type="button" aria-label="Show indexes" aria-pressed="false" title="Draw the index.md map layer alongside the concepts">
|
|
@@ -916,9 +933,9 @@
|
|
|
916
933
|
<button class="btn" id="filters-close" type="button" aria-label="Close filters" style="width:28px;height:28px"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button></div>
|
|
917
934
|
<div class="fbody">
|
|
918
935
|
<label class="search" style="max-width:none;margin-bottom:12px"><svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
|
919
|
-
<input id="filter-search" class="field" placeholder="find a type,
|
|
936
|
+
<input id="filter-search" class="field" placeholder="find a type, dir, tag…" autocomplete="off" aria-label="Find a filter"></label>
|
|
920
937
|
<div class="fgroup"><h4>Types</h4><div class="fchips" id="ftypes"></div></div>
|
|
921
|
-
<div class="fgroup"><h4>
|
|
938
|
+
<div class="fgroup"><h4>Dirs</h4><div class="fchips" id="fdirs"></div></div>
|
|
922
939
|
<div class="fgroup"><h4>Tags</h4><div class="fchips" id="ftags"></div></div>
|
|
923
940
|
</div>
|
|
924
941
|
</aside>
|
|
@@ -965,7 +982,7 @@
|
|
|
965
982
|
<label class="search" style="max-width:none;margin-bottom:12px"><svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
|
966
983
|
<input id="cat-filter-search" class="field" placeholder="find a type, area, tag…" autocomplete="off"></label>
|
|
967
984
|
<div class="fgroup"><h4>Types</h4><div class="fchips" id="cat-ftypes"></div></div>
|
|
968
|
-
<div class="fgroup"><h4>
|
|
985
|
+
<div class="fgroup"><h4>Dirs</h4><div class="fchips" id="cat-fdirs"></div></div>
|
|
969
986
|
<div class="fgroup"><h4>Tags</h4><div class="fchips" id="cat-ftags"></div></div>
|
|
970
987
|
</div>
|
|
971
988
|
</aside>
|
|
@@ -1008,7 +1025,7 @@
|
|
|
1008
1025
|
<button class="btn" id="tag-filters-close" type="button" aria-label="Close filters" style="width:28px;height:28px"><svg viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></svg></button></div>
|
|
1009
1026
|
<div class="fbody">
|
|
1010
1027
|
<div class="fgroup"><h4>Types</h4><div class="fchips" id="tag-ftypes"></div></div>
|
|
1011
|
-
<div class="fgroup"><h4>
|
|
1028
|
+
<div class="fgroup"><h4>Dirs</h4><div class="fchips" id="tag-fdirs"></div></div>
|
|
1012
1029
|
</div>
|
|
1013
1030
|
</aside>
|
|
1014
1031
|
</section>
|
|
@@ -1020,7 +1037,7 @@
|
|
|
1020
1037
|
<div class="stat-grid" id="stat-grid"></div>
|
|
1021
1038
|
<div class="two">
|
|
1022
1039
|
<div><div class="shead"><h2>By type</h2></div><div class="bars" id="bars-type"></div></div>
|
|
1023
|
-
<div><div class="shead"><h2>By
|
|
1040
|
+
<div><div class="shead"><h2>By dir</h2></div><div class="bars" id="bars-dir"></div></div>
|
|
1024
1041
|
</div>
|
|
1025
1042
|
</div>
|
|
1026
1043
|
</section>
|
|
@@ -1098,7 +1115,7 @@
|
|
|
1098
1115
|
Mermaid (lazy) fullscreen diagram viewer
|
|
1099
1116
|
inspector drag-resize splitters
|
|
1100
1117
|
graph filters layout
|
|
1101
|
-
cluster by
|
|
1118
|
+
cluster by directory file-tree mode
|
|
1102
1119
|
the authored layer fit to screen
|
|
1103
1120
|
view switching full-text search
|
|
1104
1121
|
catalog files
|
|
@@ -1115,7 +1132,7 @@
|
|
|
1115
1132
|
1. `applyGraphFilter()` is the shared sink. The search box, the three chip
|
|
1116
1133
|
groups in the slide-over, the inspector's focus chips and cluster mode
|
|
1117
1134
|
all funnel into it, and it reads `q.graph`, `activeTypes`, `activeTags`,
|
|
1118
|
-
`
|
|
1135
|
+
`activeDirs` and `ftMatch()` together. Touching any one caller can
|
|
1119
1136
|
change what the other four produce.
|
|
1120
1137
|
2. `setView()` reaches into nearly every section — it toggles the tool
|
|
1121
1138
|
groups, seeds a view on first entry via `inited`, and has to
|
|
@@ -1125,7 +1142,8 @@
|
|
|
1125
1142
|
filled by one section and read by three or four. A stale one is
|
|
1126
1143
|
invisible until an unrelated view renders wrong.
|
|
1127
1144
|
|
|
1128
|
-
The mode flags (`clustered`, `treeMode`, `ixNodes`,
|
|
1145
|
+
The mode flags (`clustered` + `clusterDepth`, `treeMode`, `ixNodes`,
|
|
1146
|
+
`ixOnly`, `view`) are
|
|
1129
1147
|
read across boundaries too: each rebuilds the graph's elements and must
|
|
1130
1148
|
undo itself exactly, or its nodes leak into the plain view.
|
|
1131
1149
|
|
|
@@ -1187,7 +1205,28 @@ const esc=s=>(s==null?'':String(s)).replace(/[&<>"']/g,c=>({'&':'&','<':'<
|
|
|
1187
1205
|
const cvar=n=>getComputedStyle(root).getPropertyValue(n).trim();
|
|
1188
1206
|
const isDark=()=>root.getAttribute('data-theme')==='dark';
|
|
1189
1207
|
const reduce=()=>matchMedia('(prefers-reduced-motion:reduce)').matches;
|
|
1190
|
-
|
|
1208
|
+
/* A concept's directory — the whole path, "." at the root — mirroring the
|
|
1209
|
+
`dir` every catalog row carries. This used to be `areaOf`, the *first path
|
|
1210
|
+
segment* only: a word the OKF spec never uses, and a projection that made
|
|
1211
|
+
nested clustering impossible because everything below level one was already
|
|
1212
|
+
thrown away before the boxes were drawn. */
|
|
1213
|
+
const dirOf=id=>{const i=id.lastIndexOf('/');return i<0?'.':id.slice(0,i);};
|
|
1214
|
+
const dirDepth=d=>d==='.'?0:d.split('/').length;
|
|
1215
|
+
const dirTrunc=(d,n)=>d==='.'?'.':d.split('/').slice(0,n).join('/');
|
|
1216
|
+
const dirParent=d=>{const i=d.lastIndexOf('/');return i<0?null:d.slice(0,i);};
|
|
1217
|
+
/* The one rule `okf --dir` uses, verbatim: a directory names itself and
|
|
1218
|
+
everything beneath it. "." needs no special case — nothing starts with "./",
|
|
1219
|
+
so the root selects only what lives directly in it. */
|
|
1220
|
+
const underDir=(d,path)=>d===path||d.indexOf(path+'/')===0;
|
|
1221
|
+
/* `.` is the data; `(root)` is what a human reads. A box caption is the last
|
|
1222
|
+
segment — a full path on every nested box would bury the picture in text. */
|
|
1223
|
+
const dirLabel=d=>d==='.'?'(root)':d.split('/').pop()+'/';
|
|
1224
|
+
/* Where a bundle lives, for the rows that name it — and only where that says
|
|
1225
|
+
something its slug does not. The slug is the name (`@okf-gem`, /b/okf-gem/);
|
|
1226
|
+
the folder is a fact about it, so a row shows the fact beside the name and
|
|
1227
|
+
drops it when the two read alike. Server-side twin: Bundle::Folder.label,
|
|
1228
|
+
which is what `title` already arrived as. */
|
|
1229
|
+
const bundleWhere=b=>b.title===b.slug?'':(b.title||'');
|
|
1191
1230
|
|
|
1192
1231
|
/* ── Cytoscape ── */
|
|
1193
1232
|
function styleSheet(){const ink=cvar('--ink'),halo=cvar('--canvas'),edge=cvar('--edge');
|
|
@@ -1474,7 +1513,9 @@ function select(id){const ele=cy.getElementById(id);if(!ele.length)return;const
|
|
|
1474
1513
|
cy.on('tap','node',e=>{const t=e.target;
|
|
1475
1514
|
if(t.hasClass('dir')){showDir(t.id().slice(DIR.length)||'.');return focusNode(t,true);}
|
|
1476
1515
|
if(t.hasClass('ix')){showDir(t.data('dir')||'.');return focusNode(t,true);}
|
|
1477
|
-
|
|
1516
|
+
/* A box's id carries the directory verbatim — "." included — so the tap needs
|
|
1517
|
+
no unmangling of a display label back into a path. */
|
|
1518
|
+
if(t.isParent()){const id=t.id();return showDir(id.indexOf('box::')===0?id.slice(5):'.');}
|
|
1478
1519
|
select(t.id());});
|
|
1479
1520
|
/* Clear the focus: drop the dim/highlight and let the hash forget the node, so a
|
|
1480
1521
|
reload does not reselect it. Tapping empty canvas does this — but a dense graph
|
|
@@ -1486,57 +1527,81 @@ cy.on('tap',e=>{if(e.target===cy)deselect();});
|
|
|
1486
1527
|
/* Three groups, one grammar: empty means everything, a click narrows, a second
|
|
1487
1528
|
click puts it back. Types used to be the odd one out — a `hiddenTypes` set
|
|
1488
1529
|
where every type showed until you clicked one *away* — so the same chip
|
|
1489
|
-
component meant "include" under
|
|
1530
|
+
component meant "include" under Dirs and Tags and "exclude" two rows above,
|
|
1490
1531
|
and meant "include" again on the catalog's own type chips one view over. That
|
|
1491
1532
|
is a rule a reader has to learn per panel instead of once. Within a group
|
|
1492
1533
|
selections union (Service *or* Charter); across groups they intersect. */
|
|
1493
|
-
const activeTypes=new Set(), activeTags=new Set(),
|
|
1534
|
+
const activeTypes=new Set(), activeTags=new Set(), activeDirs=new Set();
|
|
1494
1535
|
const searchInput=document.getElementById('search');
|
|
1495
1536
|
const q={graph:'',catalog:'',files:'',tags:''};
|
|
1496
1537
|
function applyGraphFilter(){const s=q.graph.toLowerCase();const ids=ftMatch(q.graph);
|
|
1497
1538
|
cy.nodes().forEach(n=>{if(n.isParent()||n.hasClass('dir')||n.hasClass('ix'))return;const id=n.data('id');const t=typeOf[id]||'Untyped';const nt=tagsOf[id]||[];
|
|
1498
1539
|
const hay=(n.data('title')+' '+t+' '+nt.join(' ')+' '+(descOf[id]||'')).toLowerCase();
|
|
1499
1540
|
const txt=ids?ids.has(id):(!s||hay.includes(s));
|
|
1500
|
-
const m=txt&&(activeTypes.size===0||activeTypes.has(t))&&(activeTags.size===0||nt.some(x=>activeTags.has(x)))&&(
|
|
1541
|
+
const m=txt&&(activeTypes.size===0||activeTypes.has(t))&&(activeTags.size===0||nt.some(x=>activeTags.has(x)))&&(activeDirs.size===0||[...activeDirs].some(d=>underDir(dirOf(id),d)));
|
|
1501
1542
|
n.style('display',m?'element':'none');});
|
|
1502
|
-
/* Cluster mode draws
|
|
1503
|
-
hides every concept in
|
|
1543
|
+
/* Cluster mode draws a box around each directory's concepts. When a filter
|
|
1544
|
+
hides every concept in one, hide the now-empty box too (and bring it back
|
|
1504
1545
|
when one survives) — the same rule fitBox already uses to leave stale boxes
|
|
1505
|
-
out of the fit, now applied to what is drawn.
|
|
1506
|
-
|
|
1546
|
+
out of the fit, now applied to what is drawn.
|
|
1547
|
+
Leaf descendants, not children: a box nests other boxes now, and an
|
|
1548
|
+
intermediate directory holding nothing directly has no children but its
|
|
1549
|
+
sub-boxes — `children()` read it as empty and took the whole branch beneath
|
|
1550
|
+
it off the canvas. One pass still does it, because the predicate is what
|
|
1551
|
+
knows about depth, not the loop. */
|
|
1552
|
+
cy.nodes(':parent').forEach(p=>p.style('display',p.descendants().some(c=>!c.isParent()&&c.style('display')!=='none')?'element':'none'));
|
|
1553
|
+
/* A loosened filter brings back nodes the layout never placed — they return at
|
|
1554
|
+
whatever coordinates the pre-cluster force layout left them at, dragging
|
|
1555
|
+
their box across the canvas. Re-tile when that happens. Narrowing never
|
|
1556
|
+
re-runs: the visible set only shrinks, and every survivor is already laid. */
|
|
1557
|
+
if(clustered&&clusterLaid&&cy.nodes(':visible').some(n=>!n.isParent()&&!clusterLaid.has(n.id())))clusterLayout();
|
|
1507
1558
|
ixVisibility();
|
|
1508
1559
|
relaxZoom();
|
|
1509
1560
|
bridgeSync();}
|
|
1510
|
-
function fbadge(){const c=activeTypes.size+activeTags.size+
|
|
1561
|
+
function fbadge(){const c=activeTypes.size+activeTags.size+activeDirs.size;const b=document.getElementById('btn-filters');
|
|
1511
1562
|
b.classList.toggle('on-filter',c>0);b.querySelector('.fbadge').textContent=c;ctlBadge();syncFacets();}
|
|
1512
1563
|
/* mirror the active view's tool state onto the ⚙ toggle — when the sheet is
|
|
1513
1564
|
folded away on mobile, an active filter must still call out from the bar */
|
|
1514
1565
|
function ctlBadge(){const b=document.getElementById('btn-controls');if(!b)return;
|
|
1515
1566
|
let c=0;
|
|
1516
|
-
if(view==='graph')c=activeTypes.size+activeTags.size+
|
|
1567
|
+
if(view==='graph')c=activeTypes.size+activeTags.size+activeDirs.size;
|
|
1517
1568
|
else if(view==='files'){try{c=(fileTypeCombo.get()?1:0)+(fileTagCombo.get()?1:0);}catch(err){}}
|
|
1518
1569
|
b.classList.toggle('on-filter',c>0);b.querySelector('.fbadge').textContent=c;}
|
|
1519
|
-
const ftypes=document.getElementById('ftypes'), ftags=document.getElementById('ftags'),
|
|
1520
|
-
|
|
1521
|
-
|
|
1570
|
+
const ftypes=document.getElementById('ftypes'), ftags=document.getElementById('ftags'), fdirs=document.getElementById('fdirs');
|
|
1571
|
+
/* Every directory a concept lives in, plus each of their ancestors — so a chip
|
|
1572
|
+
exists for an intermediate directory that holds nothing itself but whose
|
|
1573
|
+
subtree does. The root is listed only when concepts actually sit there. */
|
|
1574
|
+
const graphDirs=(()=>{const s=new Set();
|
|
1575
|
+
NODES.forEach(n=>{let d=dirOf(n.id);if(d==='.'){s.add('.');return;}while(d){s.add(d);d=dirParent(d);}});
|
|
1576
|
+
return [...s].sort((a,b)=>a==='.'?-1:b==='.'?1:(a<b?-1:a>b?1:0));})();
|
|
1577
|
+
/* Counted by the same prefix rule the chip filters with, so the number on a
|
|
1578
|
+
chip is exactly how many concepts clicking it leaves — the root's count is
|
|
1579
|
+
its own, since "." is a prefix of nothing. */
|
|
1580
|
+
const dirCount={};graphDirs.forEach(d=>{dirCount[d]=NODES.filter(n=>underDir(dirOf(n.id),d)).length;});
|
|
1522
1581
|
const tagsByCount=Object.entries(TAGS).sort((a,b)=>b[1].length-a[1].length).map(e=>e[0]);
|
|
1523
1582
|
const dotFor=t=>`<span class="dot" style="background:${color[t]}"></span>`;
|
|
1524
1583
|
const toggleSet=(s,v)=>{s.has(v)?s.delete(v):s.add(v);};
|
|
1525
1584
|
/* One renderer for every filter chip group (graph, catalog and tags views):
|
|
1526
1585
|
paints the items with their current state class and wires the toggle. */
|
|
1586
|
+
/* `label` is separate from `v` for one reason: the root directory's value is
|
|
1587
|
+
"." and its label "(root)", and a chip that filtered on what it displayed
|
|
1588
|
+
would be filtering on a spelling the data never uses. */
|
|
1527
1589
|
function chipRow(el,items,attr,isOn,onToggle){
|
|
1528
|
-
el.innerHTML=items.map(o=>`<span class="chip${isOn(o.v)?' on':''}" ${attr}="${esc(o.v)}">${o.dot||''}${esc(o.v)} <span class="c">${o.n}</span></span>`).join('');
|
|
1590
|
+
el.innerHTML=items.map(o=>`<span class="chip${isOn(o.v)?' on':''}" ${attr}="${esc(o.v)}">${o.dot||''}${esc(o.label==null?o.v:o.label)} <span class="c">${o.n}</span></span>`).join('');
|
|
1529
1591
|
el.querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>onToggle(ch.getAttribute(attr)));}
|
|
1530
1592
|
const typeItems=list=>list.map(t=>({v:t,n:(TYPES[t]||[]).length,dot:dotFor(t)}));
|
|
1531
|
-
const
|
|
1593
|
+
const dirItems=list=>list.map(d=>({v:d,label:d==='.'?'(root)':d,n:dirCount[d]}));
|
|
1532
1594
|
const tagItems=list=>list.map(t=>({v:t,n:(TAGS[t]||[]).length}));
|
|
1533
1595
|
/* The find box narrows the chips themselves; while it is empty, tags cap at the
|
|
1534
1596
|
top 40 so a busy bundle doesn't flood the panel — searching reaches ALL tags. */
|
|
1535
1597
|
const fsearch=document.getElementById('filter-search');
|
|
1598
|
+
/* The finder matches what the reader sees, so typing "root" reaches the chip
|
|
1599
|
+
labelled (root) even though its value is ".". */
|
|
1600
|
+
const dirMatch=qq=>d=>!qq||(d==='.'?'(root)':d).toLowerCase().includes(qq);
|
|
1536
1601
|
function syncFilterChips(){const qq=fsearch.value.trim().toLowerCase();const match=v=>!qq||v.toLowerCase().includes(qq);
|
|
1537
1602
|
const after=()=>{syncFilterChips();applyGraphFilter();fbadge();};
|
|
1538
1603
|
chipRow(ftypes,typeItems(types.filter(match)),'data-t',t=>activeTypes.has(t),t=>{toggleSet(activeTypes,t);after();});
|
|
1539
|
-
chipRow(
|
|
1604
|
+
chipRow(fdirs,dirItems(graphDirs.filter(dirMatch(qq))),'data-dir',d=>activeDirs.has(d),d=>{toggleSet(activeDirs,d);after();});
|
|
1540
1605
|
chipRow(ftags,tagItems(qq?tagsByCount.filter(match):tagsByCount.slice(0,40)),'data-tag',t=>activeTags.has(t),t=>{toggleSet(activeTags,t);after();});}
|
|
1541
1606
|
fsearch.oninput=syncFilterChips;
|
|
1542
1607
|
syncFilterChips();
|
|
@@ -1544,9 +1609,9 @@ syncFilterChips();
|
|
|
1544
1609
|
// so the container has resized), reusing the working fitGraph implementation.
|
|
1545
1610
|
function fitVisible(){requestAnimationFrame(()=>requestAnimationFrame(fitGraph));}
|
|
1546
1611
|
function focusGraphType(t){setView('graph');clearSets();activeTypes.add(t);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
|
|
1547
|
-
function
|
|
1612
|
+
function focusGraphDir(d){setView('graph');clearSets();activeDirs.add(d);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
|
|
1548
1613
|
function focusGraphTag(t){setView('graph');clearSets();activeTags.add(t);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
|
|
1549
|
-
const clearSets=()=>{activeTypes.clear();activeTags.clear();
|
|
1614
|
+
const clearSets=()=>{activeTypes.clear();activeTags.clear();activeDirs.clear();};
|
|
1550
1615
|
function clearGraphFilter(){clearSets();syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
|
|
1551
1616
|
/* Is the graph focused on exactly this facet and nothing else? Only then is a
|
|
1552
1617
|
second click on the chip an undo — with any other filter in play, clearing
|
|
@@ -1557,8 +1622,8 @@ function clearGraphFilter(){clearSets();syncFilterChips();applyGraphFilter();fba
|
|
|
1557
1622
|
on t" was `every other type hidden`, which a one-type bundle satisfies with
|
|
1558
1623
|
nothing hidden at all — so an unfiltered graph read as focused, lit the chip,
|
|
1559
1624
|
and spent the first click clearing nothing. */
|
|
1560
|
-
function typeFocused(t){return !activeTags.size&&!
|
|
1561
|
-
function tagFocused(t){return !activeTypes.size&&!
|
|
1625
|
+
function typeFocused(t){return !activeTags.size&&!activeDirs.size&&activeTypes.size===1&&activeTypes.has(t);}
|
|
1626
|
+
function tagFocused(t){return !activeTypes.size&&!activeDirs.size&&activeTags.size===1&&activeTags.has(t);}
|
|
1562
1627
|
/* The chips are filter indicators, so they repaint wherever the badge does —
|
|
1563
1628
|
that way a filter cleared from the slide-over un-lights the chip too. */
|
|
1564
1629
|
function syncFacets(){
|
|
@@ -1590,7 +1655,18 @@ async function ensureLayout(name){const ext=LAYOUT_EXT[name];if(!ext||loadedExt[
|
|
|
1590
1655
|
keeps a real gap between boxes in cose; avoidOverlap + spacingFactor do the
|
|
1591
1656
|
same for the geometric layouts. Extras are ignored by layouts that don't know
|
|
1592
1657
|
them. */
|
|
1593
|
-
|
|
1658
|
+
/* Past this many concepts a layout transition stops being orientation and starts
|
|
1659
|
+
being the reason the tab is unresponsive. Measured in nodes rather than edges
|
|
1660
|
+
because it is the node re-render that costs. */
|
|
1661
|
+
const ANIMATE_MAX=250;
|
|
1662
|
+
/* `'end'`, never `true`. The force layouts read `animate:true` as "render every
|
|
1663
|
+
tick of the simulation" — that is the visible bounce, and it is hundreds of
|
|
1664
|
+
full re-renders for one settle. `'end'` runs the same simulation headless and
|
|
1665
|
+
moves the nodes once, into the same final positions. Above ANIMATE_MAX even
|
|
1666
|
+
that one transition is dropped: at that size the move itself is the jank, and
|
|
1667
|
+
a graph that appears laid out beats a graph that arrives slowly. */
|
|
1668
|
+
function animateMode(){return cy.nodes().filter(n=>!n.isParent()).length>ANIMATE_MAX?false:'end';}
|
|
1669
|
+
function layoutOpts(name){const base={animate:animateMode(),padding:40,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2,nodeDimensionsIncludeLabels:true};
|
|
1594
1670
|
if(name==='fcose')return{...base,name:'fcose',quality:'default',nodeRepulsion:6500,idealEdgeLength:160,nodeSeparation:100,packComponents:true};
|
|
1595
1671
|
if(name==='dagre')return{...base,name:'dagre',rankDir:'TB',nodeSep:26,rankSep:55,edgeSep:12};
|
|
1596
1672
|
if(name==='cola')return{...base,name:'cola',maxSimulationTime:2500,edgeLength:160,nodeSpacing:8};
|
|
@@ -1600,8 +1676,72 @@ async function runLayout(name){if(await ensureLayout(name))cy.layout(layoutOpts(
|
|
|
1600
1676
|
layoutSel.onchange=e=>{if(clustered)return;runLayout(e.target.value);};
|
|
1601
1677
|
layoutSel.value='<%= @layout %>';
|
|
1602
1678
|
|
|
1603
|
-
/* ── cluster by
|
|
1604
|
-
|
|
1679
|
+
/* ── cluster by directory (nested compound parent nodes) ── */
|
|
1680
|
+
/* A cluster is a directory of concepts, and the boxes nest as the directories
|
|
1681
|
+
do. `clusterDepth` is how many levels down that nesting goes: at 1 the view
|
|
1682
|
+
is exactly the flat one this mode always drew (one box per first segment),
|
|
1683
|
+
at the bundle's maximum every concept sits in a box for its own directory.
|
|
1684
|
+
Both are mode state — turning clustering off has to undo them together, or
|
|
1685
|
+
the boxes leak into the plain graph. */
|
|
1686
|
+
let clustered=false,clusterDepth=1;const btnCluster=document.getElementById('btn-cluster');
|
|
1687
|
+
/* Which leaves the last cluster layout actually placed. The layout runs over the
|
|
1688
|
+
visible subset (see clusterLayout), so this is what tells a *loosened* filter
|
|
1689
|
+
apart from a tightened one: a node that comes back but is not in here has
|
|
1690
|
+
never been tiled. null while unclustered. */
|
|
1691
|
+
let clusterLaid=null;
|
|
1692
|
+
const depthSel=document.getElementById('cluster-depth'),depthWrap=document.getElementById('cluster-depth-wrap');
|
|
1693
|
+
const BOX='box::';
|
|
1694
|
+
/* `dir::` is taken — file-tree mode's folder nodes wear it — so the boxes get a
|
|
1695
|
+
prefix of their own rather than a shade of meaning on a shared one. */
|
|
1696
|
+
const maxDirDepth=Math.max(1,...NODES.map(n=>dirDepth(dirOf(n.id))));
|
|
1697
|
+
depthSel.innerHTML=Array.from({length:maxDirDepth},(_,i)=>`<option value="${i+1}">${i+1}</option>`).join('');
|
|
1698
|
+
depthSel.value=String(clusterDepth);
|
|
1699
|
+
/* Every box a depth calls for: each concept's directory truncated to N, plus
|
|
1700
|
+
every ancestor of that — so an intermediate directory holding no concepts
|
|
1701
|
+
directly still gets a box for its sub-boxes to sit in. The root box is added
|
|
1702
|
+
only when concepts live at the root, and never nests another one: it holds
|
|
1703
|
+
direct-root concepts and nothing else, which is what it always did.
|
|
1704
|
+
Shallowest first, because cy.add() reads the array in order and a compound
|
|
1705
|
+
child cannot name a parent that has not been added yet. */
|
|
1706
|
+
function boxesAt(depth){const set=new Set();
|
|
1707
|
+
NODES.forEach(n=>{const d=dirOf(n.id);if(d==='.'){set.add('.');return;}
|
|
1708
|
+
let t=dirTrunc(d,depth);while(t){set.add(t);t=dirParent(t);}});
|
|
1709
|
+
return [...set].sort((a,b)=>dirDepth(a)-dirDepth(b)||(a<b?-1:a>b?1:0));}
|
|
1710
|
+
/* `grabbable:false, pannable:true` is what keeps a box scenery rather than a
|
|
1711
|
+
handle — the pair, not either alone: ungrabbable stops the box moving, but the
|
|
1712
|
+
node still swallows the drag, and it is `pannable` that hands the gesture to
|
|
1713
|
+
the canvas the way empty background does. A cluster's
|
|
1714
|
+
empty interior is the largest drag target on the canvas, so while it grabbed
|
|
1715
|
+
the compound node, dragging to look around dragged the *directory* — and the
|
|
1716
|
+
bigger the cluster, the less of the view was pannable. Leaves stay grabbable:
|
|
1717
|
+
moving a concept is a real gesture, moving its folder is not. */
|
|
1718
|
+
function addBoxes(){
|
|
1719
|
+
cy.add(boxesAt(clusterDepth).map(d=>{const data={id:BOX+d,label:dirLabel(d)};
|
|
1720
|
+
const p=d==='.'?null:dirParent(d);if(p)data.parent=BOX+p;
|
|
1721
|
+
return {group:'nodes',data:data,grabbable:false,pannable:true};}));
|
|
1722
|
+
setBoxHandles();
|
|
1723
|
+
cy.batch(()=>NODES.forEach(n=>{const e=cy.getElementById(n.id);
|
|
1724
|
+
if(e.length)e.move({parent:BOX+dirTrunc(dirOf(n.id),clusterDepth)});}));}
|
|
1725
|
+
/* Alt hands a box back its handle. The two gestures are split by frequency, not
|
|
1726
|
+
by preference: looking around is constant and stays unmodified, tidying a box
|
|
1727
|
+
is occasional and takes the modifier. Alt rather than Ctrl because on macOS
|
|
1728
|
+
Ctrl+mousedown is the system secondary click, and a modifier that opens a
|
|
1729
|
+
context menu instead of dragging is not a modifier.
|
|
1730
|
+
|
|
1731
|
+
`blur` is not defensive clutter — a keyup that lands on another window never
|
|
1732
|
+
reaches us, so without it the boxes would still be handles when the reader
|
|
1733
|
+
comes back and every drag would move the wrong thing. */
|
|
1734
|
+
let altHeld=false;
|
|
1735
|
+
function setBoxHandles(){cy.nodes(':parent').forEach(n=>{
|
|
1736
|
+
if(altHeld){n.grabify();n.unpanify();}else{n.ungrabify();n.panify();}});}
|
|
1737
|
+
addEventListener('keydown',e=>{if(e.key==='Alt'&&!altHeld){altHeld=true;setBoxHandles();}});
|
|
1738
|
+
addEventListener('keyup',e=>{if(e.key==='Alt'&&altHeld){altHeld=false;setBoxHandles();}});
|
|
1739
|
+
addEventListener('blur',()=>{if(altHeld){altHeld=false;setBoxHandles();}});
|
|
1740
|
+
/* Unparent first, then take the boxes out as one collection — removing a nested
|
|
1741
|
+
parent while its children still point at it is how a teardown leaves orphans. */
|
|
1742
|
+
function removeBoxes(){cy.batch(()=>{
|
|
1743
|
+
NODES.forEach(n=>{const e=cy.getElementById(n.id);if(e.length&&e.isChild())e.move({parent:null});});
|
|
1744
|
+
cy.nodes().filter(n=>n.id().indexOf(BOX)===0).remove();});}
|
|
1605
1745
|
/* cose treats compounds as an afterthought — nodes with no links drift to
|
|
1606
1746
|
opposite corners of a huge half-empty box, and the boxes themselves sprawl —
|
|
1607
1747
|
so grouped views needed hand-tidying every time. fcose is the engine built
|
|
@@ -1610,27 +1750,48 @@ let clustered=false;const btnCluster=document.getElementById('btn-cluster');
|
|
|
1610
1750
|
captions from overlapping. Clustering therefore always uses it, with cose
|
|
1611
1751
|
kept only for when the CDN is unreachable (a saved render opened offline
|
|
1612
1752
|
still groups, just less prettily). */
|
|
1753
|
+
/* Laid out over the *visible* elements only. Not a nicety: fcose measures every
|
|
1754
|
+
node it is handed, and a nested compound graph whose nodes went `display:none`
|
|
1755
|
+
mid-run threw on a label it could no longer measure — reachable by typing a
|
|
1756
|
+
term that matches nothing while the tiling animation is still going. Handing
|
|
1757
|
+
it the visible subset also happens to be the right answer, since a hidden
|
|
1758
|
+
concept has no business influencing where the visible ones land. Nothing
|
|
1759
|
+
visible means nothing to lay out. */
|
|
1613
1760
|
function clusterLayout(){ensureLayout('fcose').then(ok=>{if(!clustered)return;
|
|
1614
|
-
cy.
|
|
1761
|
+
const eles=cy.elements(':visible');
|
|
1762
|
+
/* Recorded before the early return, not after: "nothing was visible" and
|
|
1763
|
+
"nothing has been laid out" are the same fact, and the empty set is what
|
|
1764
|
+
makes clearing that filter re-tile everything rather than nothing. */
|
|
1765
|
+
clusterLaid=new Set(eles.nodes().filter(n=>!n.isParent()).map(n=>n.id()));
|
|
1766
|
+
if(!eles.nodes().nonempty())return;
|
|
1767
|
+
cy.layout(ok?{name:'fcose',eles:eles,quality:'proof',animate:animateMode(),padding:40,nodeRepulsion:6500,idealEdgeLength:90,
|
|
1615
1768
|
nodeSeparation:100,nestingFactor:.1,gravityCompound:1.5,tile:true,tilingPaddingVertical:14,
|
|
1616
1769
|
tilingPaddingHorizontal:14,packComponents:true,nodeDimensionsIncludeLabels:true}
|
|
1617
|
-
:{name:'cose',animate:
|
|
1770
|
+
:{name:'cose',eles:eles,animate:animateMode(),padding:40,nodeRepulsion:8000,idealEdgeLength:70,nodeOverlap:24,nestingFactor:1.2,gravity:.7}).run();});}
|
|
1618
1771
|
function setClustered(on){if(on===clustered)return;if(on&&treeMode)setTree(false);
|
|
1619
1772
|
clustered=on;btnCluster.setAttribute('aria-pressed',String(on));layoutSel.disabled=on;
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1773
|
+
/* A flat bundle has one level and therefore no choice to offer — the control
|
|
1774
|
+
stays away rather than presenting a select with a single option. */
|
|
1775
|
+
depthWrap.hidden=!(on&&maxDirDepth>1);
|
|
1776
|
+
clusterLaid=null;
|
|
1777
|
+
if(on){
|
|
1778
|
+
addBoxes();
|
|
1779
|
+
/* A box is made for every directory; if a search or filter was already
|
|
1780
|
+
narrowing the graph, the ones it emptied would cluster as phantom boxes.
|
|
1781
|
+
Re-apply the filter now — before the layout tiles them — so empty boxes
|
|
1782
|
+
never get drawn. (Filtering while already clustered runs applyGraphFilter
|
|
1783
|
+
on its own.) */
|
|
1627
1784
|
applyGraphFilter();
|
|
1628
1785
|
clusterLayout();
|
|
1629
1786
|
}else{
|
|
1630
|
-
|
|
1631
|
-
cy.nodes().filter(n=>n.id().indexOf('area::')===0).remove();
|
|
1787
|
+
removeBoxes();
|
|
1632
1788
|
runLayout(layoutSel.value);}}
|
|
1633
1789
|
btnCluster.onclick=()=>setClustered(!clustered);
|
|
1790
|
+
/* Changing depth rebuilds rather than re-parents: the box *set* changes with it
|
|
1791
|
+
(depth 2 adds boxes depth 1 never had), so tearing down and building again is
|
|
1792
|
+
both simpler and the only version with one code path to get right. */
|
|
1793
|
+
depthSel.onchange=()=>{clusterDepth=+depthSel.value;if(!clustered)return;
|
|
1794
|
+
removeBoxes();addBoxes();applyGraphFilter();clusterLayout();};
|
|
1634
1795
|
|
|
1635
1796
|
/* ── file-tree mode: folders become nodes and the only edges are folder→child,
|
|
1636
1797
|
so the picture is the acyclic files-and-folders tree, not the link graph. ── */
|
|
@@ -1731,10 +1892,12 @@ function openMapInGraph(dir){setView('graph');showDir(dir);
|
|
|
1731
1892
|
visible leaves plus the area boxes drawn around them. A compound parent is
|
|
1732
1893
|
26px of padding wider than its children on every side and carries its label
|
|
1733
1894
|
above that, so fitting to the children alone crops the boxes and their names
|
|
1734
|
-
off the edges. A parent
|
|
1735
|
-
|
|
1895
|
+
off the edges. A parent with no visible *leaf* descendant is left out too — it
|
|
1896
|
+
collapses to a stale box that would inflate the fit, and leaves are the test
|
|
1897
|
+
because a box nests other boxes now. */
|
|
1736
1898
|
function fitBox(){const vis=cy.nodes(':visible');
|
|
1737
|
-
const eles=vis.filter(n=>!n.isParent())
|
|
1899
|
+
const eles=vis.filter(n=>!n.isParent())
|
|
1900
|
+
.union(vis.filter(n=>n.isParent()&&n.descendants().some(c=>!c.isParent()&&c.style('display')!=='none')));
|
|
1738
1901
|
const use=eles.length?eles:cy.nodes();
|
|
1739
1902
|
return use.length?use.boundingBox():null;}
|
|
1740
1903
|
|
|
@@ -1788,17 +1951,19 @@ function setView(v){if(v===view)return;view=v;document.getElementById('app').set
|
|
|
1788
1951
|
/* the count and the dead-end offer belong to the view's own query, so they
|
|
1789
1952
|
re-decide on every switch — including into a view that has no box at all */
|
|
1790
1953
|
bridgeSync();}
|
|
1791
|
-
/*
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1954
|
+
/* Which rail item the page is standing on — the view, except where two rail
|
|
1955
|
+
items share one. Index is a shortcut into Files that opens the root map, so
|
|
1956
|
+
while that map is what is open the reader is standing on Index and the rail
|
|
1957
|
+
has to say so; anything else in Files, a *nested* index.md included, is
|
|
1958
|
+
Files. Returning `view` flat lit Files on the one screen the reader reached by
|
|
1959
|
+
asking for Index, which reads as the click having gone somewhere else. */
|
|
1960
|
+
const ROOT_INDEX='index.md';
|
|
1961
|
+
function activeRail(){return view==='files'&&fileSel===ROOT_INDEX?'index':view;}
|
|
1797
1962
|
/* And everything that has to *go* somewhere clicks the rail item rather than
|
|
1798
|
-
calling setView:
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1963
|
+
calling setView: "index" is not a view, so setView alone lands on one that
|
|
1964
|
+
does not exist, and even for Files it would arrive without opening the map
|
|
1965
|
+
that makes it Index. The rail button already gets this right; this is how the
|
|
1966
|
+
keyboard and the palette borrow it instead of drifting from it. */
|
|
1802
1967
|
function goRail(v){const b=document.querySelector('.rail-item[data-view="'+v+'"]');if(b)b.click();}
|
|
1803
1968
|
function railActive(){const v=activeRail();
|
|
1804
1969
|
document.querySelectorAll('.rail-item').forEach(b=>b.classList.toggle('active',b.dataset.view===v));}
|
|
@@ -1873,38 +2038,38 @@ let CATALOG=null;
|
|
|
1873
2038
|
otherwise) and fills descOf on the way, so even the pre-index substring
|
|
1874
2039
|
fallback can match a graph node by its leaf description. */
|
|
1875
2040
|
function getCatalog(){return CATALOG||(CATALOG=(EMBED?Promise.resolve(EMBED.catalog):fetch(CATALOG_ENDPOINT).then(r=>r.json()).then(d=>d.concepts)).then(list=>{list.forEach(c=>{descOf[c.id]=c.description||'';});return list;}));}
|
|
1876
|
-
const catActiveTypes=new Set(),
|
|
2041
|
+
const catActiveTypes=new Set(), catActiveDirs=new Set(), catActiveTags=new Set();
|
|
1877
2042
|
const catByCount=types.slice().sort((a,b)=>(TYPES[b]||[]).length-(TYPES[a]||[]).length);
|
|
1878
2043
|
const catChip=t=>`<span class="chip" data-t="${esc(t)}"><span class="dot" style="background:${color[t]}"></span>${esc(t)} <span class="c">${(TYPES[t]||[]).length}</span></span>`;
|
|
1879
2044
|
function syncCatChips(){document.querySelectorAll('#cat-types .chip').forEach(c=>c.classList.toggle('on',catActiveTypes.has(c.getAttribute('data-t'))));
|
|
1880
|
-
const n=catActiveTypes.size+
|
|
2045
|
+
const n=catActiveTypes.size+catActiveDirs.size+catActiveTags.size;
|
|
1881
2046
|
const b=document.getElementById('cat-filters-btn');b.classList.toggle('on-filter',n>0);b.querySelector('.fbadge').textContent=n;}
|
|
1882
2047
|
function catToggle(t){toggleSet(catActiveTypes,t);renderCatFilters();renderCatalog();}
|
|
1883
2048
|
function wireCatChips(el){el.querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>catToggle(ch.getAttribute('data-t')));}
|
|
1884
|
-
/* The slide-over mirrors the graph filters — Types,
|
|
2049
|
+
/* The slide-over mirrors the graph filters — Types, Dirs, Tags — with one find
|
|
1885
2050
|
box narrowing the chips of all three groups (tags cap at 40 until searched). */
|
|
1886
2051
|
function renderCatFilters(){const qq=(document.getElementById('cat-filter-search').value||'').trim().toLowerCase();
|
|
1887
2052
|
const match=v=>!qq||v.toLowerCase().includes(qq);
|
|
1888
2053
|
const after=()=>{renderCatFilters();renderCatalog();};
|
|
1889
2054
|
chipRow(document.getElementById('cat-ftypes'),typeItems(catByCount.filter(match)),'data-t',t=>catActiveTypes.has(t),t=>{toggleSet(catActiveTypes,t);after();});
|
|
1890
|
-
chipRow(document.getElementById('cat-
|
|
2055
|
+
chipRow(document.getElementById('cat-fdirs'),dirItems(graphDirs.filter(dirMatch(qq))),'data-dir',d=>catActiveDirs.has(d),d=>{toggleSet(catActiveDirs,d);after();});
|
|
1891
2056
|
chipRow(document.getElementById('cat-ftags'),tagItems(qq?tagsByCount.filter(match):tagsByCount.slice(0,40)),'data-tag',t=>catActiveTags.has(t),t=>{toggleSet(catActiveTags,t);after();});
|
|
1892
2057
|
syncCatChips();}
|
|
1893
2058
|
function initCatalog(){inited.catalog=true;
|
|
1894
2059
|
// Only the five most common types get inline chips; everything else — types,
|
|
1895
|
-
//
|
|
2060
|
+
// dirs, tags — lives in the searchable slide-over, so a bundle with many
|
|
1896
2061
|
// values doesn't flood the header.
|
|
1897
2062
|
const quick=document.getElementById('cat-types');quick.innerHTML=catByCount.slice(0,5).map(catChip).join('');wireCatChips(quick);
|
|
1898
2063
|
renderCatFilters();
|
|
1899
2064
|
const cf=document.getElementById('cat-filters');
|
|
1900
2065
|
document.getElementById('cat-filters-btn').onclick=()=>cf.classList.toggle('open');
|
|
1901
2066
|
document.getElementById('cat-filters-close').onclick=()=>cf.classList.remove('open');
|
|
1902
|
-
document.getElementById('cat-filters-reset').onclick=()=>{catActiveTypes.clear();
|
|
2067
|
+
document.getElementById('cat-filters-reset').onclick=()=>{catActiveTypes.clear();catActiveDirs.clear();catActiveTags.clear();renderCatFilters();renderCatalog();};
|
|
1903
2068
|
document.getElementById('cat-filter-search').oninput=renderCatFilters;
|
|
1904
2069
|
getCatalog().then(renderCatalog);}
|
|
1905
2070
|
function renderCatalog(){if(!CATALOG)return;getCatalog().then(list=>{const s=(q.catalog||'').toLowerCase();const ids=ftMatch(q.catalog);
|
|
1906
2071
|
const rows=list.filter(c=>{if(catActiveTypes.size&&!catActiveTypes.has(c.type))return false;
|
|
1907
|
-
if(
|
|
2072
|
+
if(catActiveDirs.size&&![...catActiveDirs].some(d=>underDir(dirOf(c.id),d)))return false;
|
|
1908
2073
|
if(catActiveTags.size&&!c.tags.some(t=>catActiveTags.has(t)))return false;
|
|
1909
2074
|
if(ids)return ids.has(c.id);
|
|
1910
2075
|
if(s){const hay=(c.title+' '+c.description+' '+c.type+' '+c.tags.join(' ')+' '+c.id).toLowerCase();if(!hay.includes(s))return false;}return true;});
|
|
@@ -2106,6 +2271,7 @@ function openFile(id,list){fileSel=id;
|
|
|
2106
2271
|
if(ixOnly)setIxOnly(false);
|
|
2107
2272
|
const c=(list||[]).find(x=>x.id===id)||byId[id]||{};
|
|
2108
2273
|
document.querySelectorAll('.file').forEach(b=>b.classList.toggle('sel',b.dataset.id===id));
|
|
2274
|
+
railActive(); /* the rail reads the open file to tell Index from Files */
|
|
2109
2275
|
const ty=c.type||typeOf[id]||'Untyped';const cc=color[ty]||'#64748b';
|
|
2110
2276
|
document.getElementById('fp-head').hidden=false;
|
|
2111
2277
|
document.getElementById('fp-type').innerHTML=`<span class="dot" style="background:${cc}"></span>${esc(ty)}`;
|
|
@@ -2118,6 +2284,7 @@ function openFile(id,list){fileSel=id;
|
|
|
2118
2284
|
log fetched fresh on every click so a just-appended entry shows. */
|
|
2119
2285
|
function openReserved(kind,path){fileSel=path;
|
|
2120
2286
|
document.querySelectorAll('.file').forEach(b=>b.classList.toggle('sel',b.dataset.path===path));
|
|
2287
|
+
railActive(); /* the rail reads the open file to tell Index from Files */
|
|
2121
2288
|
document.getElementById('fp-head').hidden=false;
|
|
2122
2289
|
document.getElementById('fp-title').textContent=path;
|
|
2123
2290
|
const d=dirOfPath(path);
|
|
@@ -2139,21 +2306,21 @@ function openReserved(kind,path){fileSel=path;
|
|
|
2139
2306
|
LOGS=null;getLogs().then(logs=>{const l=logs.find(x=>x.path===path);done((l&&l.content)||'');});}}
|
|
2140
2307
|
|
|
2141
2308
|
/* ── tags ── */
|
|
2142
|
-
const tagSel=new Set(), tagTypes=new Set(),
|
|
2143
|
-
/* Does this concept survive the tags view's type/
|
|
2309
|
+
const tagSel=new Set(), tagTypes=new Set(), tagDirs=new Set();
|
|
2310
|
+
/* Does this concept survive the tags view's type/dir filters? Tags are counted
|
|
2144
2311
|
and listed over the surviving concepts only; a tag left with none disappears. */
|
|
2145
|
-
function tagMatch(id){return (tagTypes.size===0||tagTypes.has(typeOf[id]||'Untyped'))&&(
|
|
2312
|
+
function tagMatch(id){return (tagTypes.size===0||tagTypes.has(typeOf[id]||'Untyped'))&&(tagDirs.size===0||[...tagDirs].some(d=>underDir(dirOf(id),d)));}
|
|
2146
2313
|
function renderTagChips(){
|
|
2147
2314
|
const after=()=>{renderTagChips();renderCloud();};
|
|
2148
2315
|
chipRow(document.getElementById('tag-ftypes'),typeItems(types),'data-t',t=>tagTypes.has(t),t=>{toggleSet(tagTypes,t);after();});
|
|
2149
|
-
chipRow(document.getElementById('tag-
|
|
2150
|
-
const n=tagTypes.size+
|
|
2316
|
+
chipRow(document.getElementById('tag-fdirs'),dirItems(graphDirs),'data-dir',d=>tagDirs.has(d),d=>{toggleSet(tagDirs,d);after();});
|
|
2317
|
+
const n=tagTypes.size+tagDirs.size;const b=document.getElementById('tag-filters-btn');
|
|
2151
2318
|
b.classList.toggle('on-filter',n>0);b.querySelector('.fbadge').textContent=n;}
|
|
2152
2319
|
function initTags(){inited.tags=true;
|
|
2153
2320
|
const tf=document.getElementById('tag-filters');
|
|
2154
2321
|
document.getElementById('tag-filters-btn').onclick=()=>tf.classList.toggle('open');
|
|
2155
2322
|
document.getElementById('tag-filters-close').onclick=()=>tf.classList.remove('open');
|
|
2156
|
-
document.getElementById('tag-filters-reset').onclick=()=>{tagTypes.clear();
|
|
2323
|
+
document.getElementById('tag-filters-reset').onclick=()=>{tagTypes.clear();tagDirs.clear();renderTagChips();renderCloud();};
|
|
2157
2324
|
renderTagChips();renderCloud();}
|
|
2158
2325
|
function renderCloud(){const s=(q.tags||'').toLowerCase();
|
|
2159
2326
|
// Uniform chip size, ordered by frequency (descending); the background tint scales
|
|
@@ -2183,19 +2350,23 @@ function countUp(el,to){if(reduce()||to<=8){el.textContent=to;return;}let start=
|
|
|
2183
2350
|
function step(ts){if(!start)start=ts;const p=Math.min(1,(ts-start)/650);el.textContent=Math.round(to*(1-Math.pow(1-p,3)));if(p<1)requestAnimationFrame(step);}
|
|
2184
2351
|
requestAnimationFrame(step);}
|
|
2185
2352
|
function initStats(){inited.stats=true;
|
|
2186
|
-
const
|
|
2187
|
-
const stats=[['Concepts',NODES.length],['
|
|
2353
|
+
const dirs=new Set(NODES.map(n=>dirOf(n.id)));
|
|
2354
|
+
const stats=[['Concepts',NODES.length],['Dirs',dirs.size],['Concept types',types.length],['Cross-links',EDGES.length],['Distinct tags',Object.keys(TAGS).length]];
|
|
2188
2355
|
const g=document.getElementById('stat-grid');
|
|
2189
2356
|
g.innerHTML=stats.map(s=>`<div class="stat"><div class="n" data-to="${s[1]}">0</div><div class="l">${s[0]}</div></div>`).join('');
|
|
2190
2357
|
g.querySelectorAll('.n').forEach(el=>countUp(el,+el.dataset.to));
|
|
2191
2358
|
const byType=Object.entries(TYPES).map(([t,ids])=>[t,ids.length]).sort((a,b)=>b[1]-a[1]);
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2359
|
+
/* Direct counts, keyed by the whole path — the same cut `okf stats --json`
|
|
2360
|
+
emits as `by_dir`, so the panel and the CLI answer alike. The bar's value is
|
|
2361
|
+
the directory; only its label is softened to (root). */
|
|
2362
|
+
const dirCnt={};NODES.forEach(n=>{const d=dirOf(n.id);dirCnt[d]=(dirCnt[d]||0)+1;});
|
|
2363
|
+
const byDir=Object.entries(dirCnt).sort((a,b)=>b[1]-a[1]);
|
|
2364
|
+
renderBars('bars-type',byType,t=>color[t],'type');renderBars('bars-dir',byDir,()=>cvar('--accent'),'dir');}
|
|
2195
2365
|
function renderBars(id,rows,colFn,kind){const max=Math.max(1,...rows.map(r=>r[1]));const box=document.getElementById(id);
|
|
2366
|
+
const label=l=>kind==='dir'&&l==='.'?'(root)':l;
|
|
2196
2367
|
box.innerHTML=rows.map(([l,v])=>
|
|
2197
|
-
`<div class="bar clickable" data-val="${esc(l)}" role="button" tabindex="0" title="Show ${esc(l)} in the graph"><span class="bl">${esc(l)}</span><div class="bt"><div class="bf" style="background:${colFn(l)};transform:scaleX(${reduce()?1:0})" data-w="${v/max}"></div></div><span class="bv">${v}</span></div>`).join('');
|
|
2198
|
-
box.querySelectorAll('.bar').forEach(b=>{const go=()=>{const val=b.dataset.val;kind==='type'?focusGraphType(val):
|
|
2368
|
+
`<div class="bar clickable" data-val="${esc(l)}" role="button" tabindex="0" title="Show ${esc(label(l))} in the graph"><span class="bl">${esc(label(l))}</span><div class="bt"><div class="bf" style="background:${colFn(l)};transform:scaleX(${reduce()?1:0})" data-w="${v/max}"></div></div><span class="bv">${v}</span></div>`).join('');
|
|
2369
|
+
box.querySelectorAll('.bar').forEach(b=>{const go=()=>{const val=b.dataset.val;kind==='type'?focusGraphType(val):focusGraphDir(val);};
|
|
2199
2370
|
b.onclick=go;b.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();go();}};});
|
|
2200
2371
|
requestAnimationFrame(()=>box.querySelectorAll('.bf').forEach(f=>f.style.transform=`scaleX(${f.dataset.w})`));}
|
|
2201
2372
|
|
|
@@ -2287,10 +2458,15 @@ var openPalette=null;
|
|
|
2287
2458
|
and layout a bundle switch preserves are exactly what a node has to override.
|
|
2288
2459
|
A hit in *this* bundle is not a navigation at all; go() selects it in place
|
|
2289
2460
|
and this href only exists for ⌘-click, where a second tab is the point. */
|
|
2290
|
-
|
|
2461
|
+
/* Three cases, not two. A hub row names another bundle and is addressed
|
|
2462
|
+
relative to its mount; a hub row for *this* bundle stays put; and a
|
|
2463
|
+
standalone row carries no slug at all, because one bundle has none to carry.
|
|
2464
|
+
Folding that third case into the first built '../undefined/' — a 404 on
|
|
2465
|
+
every result the palette returned. */
|
|
2466
|
+
const hitHref=c=>(c.slug==null||c.slug===SELF_SLUG?'':'../'+encodeURIComponent(c.slug)+'/')+'?select='+encodeURIComponent(c.id);
|
|
2291
2467
|
const hitHtml=c=>`<span class="hit-top"><span class="hit-t">${hilite(c.title||c.id,hitTerms)}</span>`+
|
|
2292
2468
|
(c.type?`<span class="hit-type">${esc(c.type)}</span>`:'')+
|
|
2293
|
-
|
|
2469
|
+
(c.slug==null?'':`<span class="slug">${esc(c.slug)}</span>`)+`</span>`+
|
|
2294
2470
|
(c.snippet?`<span class="hit-s">${hilite(c.snippet,hitTerms)}</span>`:'');
|
|
2295
2471
|
/* Bundles lead, and on an empty box they are the only thing here: switching
|
|
2296
2472
|
bundles is what the palette is for, and six views above them would push the
|
|
@@ -2312,11 +2488,17 @@ var openPalette=null;
|
|
|
2312
2488
|
let h='';
|
|
2313
2489
|
if(showB){
|
|
2314
2490
|
if(heads)h+=grp('Bundles');
|
|
2315
|
-
|
|
2491
|
+
/* The you-are-here row wears the same name as the rows under it — `@slug`
|
|
2492
|
+
where there is one to wear. A standalone page has none, and falls back to
|
|
2493
|
+
the header's own wording rather than inventing a ref that addresses
|
|
2494
|
+
nothing. */
|
|
2495
|
+
if(!q)h+=`<a class="cur" aria-disabled="true"><span>${SELF_SLUG?'@'+esc(SELF_SLUG):esc(IDENT)}</span><span class="sw-where">current</span></a>`;
|
|
2316
2496
|
if(!SIBLINGS.length)h+='<a class="none"><span>only this bundle is registered — okf registry set <dir> adds more</span></a>';
|
|
2317
|
-
else h+=bs.map((b,i)=>
|
|
2318
|
-
|
|
2319
|
-
|
|
2497
|
+
else h+=bs.map((b,i)=>{const where=bundleWhere(b);
|
|
2498
|
+
return opt(i,{
|
|
2499
|
+
attrs:`href="${esc(target(b.path))}" data-path="${esc(b.path)}"`,
|
|
2500
|
+
html:`<span>@${esc(b.slug)}${b.default?'<span class="sw-def">default</span>':''}</span>`+
|
|
2501
|
+
(where?`<span class="sw-where">${esc(where)}</span>`:'')});}).join('');
|
|
2320
2502
|
}
|
|
2321
2503
|
if(vs.length){
|
|
2322
2504
|
if(heads)h+=grp('Views');
|
|
@@ -2329,7 +2511,7 @@ var openPalette=null;
|
|
|
2329
2511
|
if(heads)h+=grp('Concepts');
|
|
2330
2512
|
h+=cs.map((c,i)=>opt(bs.length+vs.length+i,{
|
|
2331
2513
|
cls:' sw-hit',
|
|
2332
|
-
attrs:`href="${esc(hitHref(c))}" data-hit="${esc(c.id)}" data-slug="${esc(c.slug)}"`,
|
|
2514
|
+
attrs:`href="${esc(hitHref(c))}" data-hit="${esc(c.id)}" data-slug="${esc(c.slug==null?'':c.slug)}"`,
|
|
2333
2515
|
html:hitHtml(c)})).join('');
|
|
2334
2516
|
/* The cap is reported, never silent: a palette that shows 50 of 214 and says
|
|
2335
2517
|
nothing has told the reader the answer is complete. */
|
|
@@ -2363,10 +2545,15 @@ var openPalette=null;
|
|
|
2363
2545
|
const id=a.dataset.hit;
|
|
2364
2546
|
/* A concept in this bundle is already loaded — selecting it in place keeps the
|
|
2365
2547
|
camera, the filters and the layout the reader built. Only a hit in another
|
|
2366
|
-
bundle is worth a page load.
|
|
2548
|
+
bundle is worth a page load.
|
|
2549
|
+
Empty slug means a standalone server: one bundle, so every hit is this one.
|
|
2550
|
+
It reads as '' rather than SELF_SLUG's null because the row simply has no
|
|
2551
|
+
slug key, and comparing the two directly sent every standalone hit down the
|
|
2552
|
+
page-load branch — the whole index reloaded to reach a node already on
|
|
2553
|
+
screen. */
|
|
2367
2554
|
if(id!==undefined){const t=a.getAttribute('href');
|
|
2368
2555
|
if(newTab){window.open(t,'_blank');close();return;}
|
|
2369
|
-
if(a.dataset.slug===SELF_SLUG){close();goToGraph(id);return;}
|
|
2556
|
+
if(a.dataset.slug===''||a.dataset.slug===SELF_SLUG){close();goToGraph(id);return;}
|
|
2370
2557
|
location.href=t;return;}
|
|
2371
2558
|
const p=a.dataset.path;if(!p)return;const t=target(p);
|
|
2372
2559
|
if(newTab){window.open(t,'_blank');close();}else{location.href=t;}}
|
|
@@ -2412,9 +2599,13 @@ var openPalette=null;
|
|
|
2412
2599
|
is switching bundles. Standalone has neither, so there the view is the job.
|
|
2413
2600
|
The ⇄ button keeps the bundle wording in every mode: it draws a swap, and a
|
|
2414
2601
|
control whose tooltip disagrees with its icon teaches the wrong thing. */
|
|
2415
|
-
|
|
2602
|
+
/* FINDS no longer implies HUB: a standalone server answers /search over the one
|
|
2603
|
+
bundle it was given, so the copy has to name what is actually on offer rather
|
|
2604
|
+
than assume a set is behind it. Four states, because both bits are real. */
|
|
2605
|
+
const label=FINDS?(HUB?'Search all bundles':'Search concepts'):HUB?'Switch bundle':'Jump to a view';
|
|
2416
2606
|
const btnLabel=HUB?'Switch bundle':label;
|
|
2417
|
-
input.placeholder=FINDS?'search concepts, switch bundle, or a view…':
|
|
2607
|
+
input.placeholder=FINDS?(HUB?'search concepts, switch bundle, or a view…':'search concepts, or a view…')
|
|
2608
|
+
:HUB?'switch bundle, or type a view…':'jump to a view…';
|
|
2418
2609
|
input.setAttribute('aria-label',label);
|
|
2419
2610
|
ov.setAttribute('aria-label',FINDS?label:HUB?'Switch bundle':'Command palette');
|
|
2420
2611
|
document.getElementById('sw-hint').textContent='↑↓ move · ⏎ open · '+(HUB?(isMac?'⌘⏎':'Ctrl-⏎')+' new tab · ':'')+'Esc close';
|
|
@@ -2625,12 +2816,17 @@ function bridgeReport(v,shown,total){if(bridge)bridge.report(v,shown,total);}
|
|
|
2625
2816
|
/* A row the hub could not load links nowhere, because there is nowhere to
|
|
2626
2817
|
link — but it is still a row: "where did my bundle go?" is the question it
|
|
2627
2818
|
exists to answer. */
|
|
2819
|
+
/* Named by slug, for the reason the palette rows are: the slug is what
|
|
2820
|
+
addresses this bundle, the folder is where it happens to sit. Unlike the
|
|
2821
|
+
hub's own /b/ page this row carries no full path, so the folder stays — as
|
|
2822
|
+
the fact under the name, and only where it is not the name repeated. */
|
|
2628
2823
|
const title=b.mount!==null
|
|
2629
|
-
?'<a class="ws-title" href="../'+encodeURIComponent(b.mount)+'/"
|
|
2630
|
-
:'<span class="ws-title off"
|
|
2824
|
+
?'<a class="ws-title" href="../'+encodeURIComponent(b.mount)+'/">@'+esc(b.slug)+'</a>'
|
|
2825
|
+
:'<span class="ws-title off">@'+esc(b.slug)+'</span>';
|
|
2826
|
+
const where=bundleWhere(b);
|
|
2631
2827
|
const count=b.count===null?'':'<span>'+b.count+' concept'+(b.count===1?'':'s')+'</span>';
|
|
2632
2828
|
const body='<div class="ws-row-main"><div class="ws-t">'+title+pills+'</div>'+
|
|
2633
|
-
'<div class="ws-meta"
|
|
2829
|
+
'<div class="ws-meta">'+(where?'<span class="ws-where">'+esc(where)+'</span>':'')+count+
|
|
2634
2830
|
'<span class="ws-health '+esc(b.health)+'">'+esc(b.word)+'</span></div>'+
|
|
2635
2831
|
(editing===b.slug?edit(b):'')+(confirming===b.slug?confirm(b):'')+'</div>';
|
|
2636
2832
|
return '<div class="ws-row" data-slug="'+esc(b.slug)+'">'+body+(manageable()?menuBtn(b):'')+'</div>';}
|
|
@@ -3004,10 +3200,11 @@ const KEYS=[
|
|
|
3004
3200
|
[ '/', 'search the current view (where it has one)' ],
|
|
3005
3201
|
[ '\\', 'show or hide the inspector — graph' ],
|
|
3006
3202
|
[ '0', 'fit the graph to the screen — graph' ],
|
|
3007
|
-
[ 'f', 'fullscreen' ],
|
|
3008
3203
|
[ /Mac|iP/.test(navigator.platform||'')?'⌘K':'Ctrl-K',
|
|
3009
|
-
SEARCH_ENDPOINT!==null?'search every bundle, switch bundle, or jump to a view'
|
|
3010
|
-
|
|
3204
|
+
SEARCH_ENDPOINT!==null?(HUB_PATH!==null?'search every bundle, switch bundle, or jump to a view'
|
|
3205
|
+
:'search this bundle, or jump to a view')
|
|
3206
|
+
:HUB_PATH!==null?'switch bundle, or jump to a view':'jump to a view' ],
|
|
3207
|
+
[ '⌥ drag', 'move a cluster box — a plain drag pans the canvas' ],
|
|
3011
3208
|
[ '?', 'this list' ],
|
|
3012
3209
|
[ 'Esc', 'close what is open, or clear the graph selection' ]
|
|
3013
3210
|
];
|
|
@@ -3034,7 +3231,7 @@ addEventListener('keydown',e=>{if(!dgv.hidden){if(e.key==='Escape')closeDiagram(
|
|
|
3034
3231
|
else if(e.key==='\\'&&view==='graph'){setSide(gbody.getAttribute('data-side')==='hidden'?'default':'hidden');}
|
|
3035
3232
|
else if(e.key==='0'&&view==='graph'){fitGraph();}
|
|
3036
3233
|
else if(e.key==='Escape'&&view==='graph'){deselect();}
|
|
3037
|
-
|
|
3234
|
+
});
|
|
3038
3235
|
|
|
3039
3236
|
/* ── deep links ── */
|
|
3040
3237
|
const Q=new URLSearchParams(location.search),QL=Q.get('layout'),QS=Q.get('select');
|