okf 1.10.0 → 1.12.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 +294 -0
- data/README.md +310 -445
- data/lib/okf/bundle/folder.rb +24 -5
- data/lib/okf/bundle/linter.rb +1 -1
- data/lib/okf/bundle/search/index.rb +13 -3
- data/lib/okf/bundle/search.rb +93 -13
- data/lib/okf/bundle/skeleton.rb +241 -0
- data/lib/okf/bundle.rb +19 -14
- data/lib/okf/cli/catalog.rb +6 -6
- data/lib/okf/cli/command.rb +241 -14
- data/lib/okf/cli/dirs.rb +118 -0
- data/lib/okf/cli/files.rb +2 -2
- data/lib/okf/cli/graph.rb +115 -9
- data/lib/okf/cli/index.rb +67 -25
- data/lib/okf/cli/loose.rb +2 -2
- data/lib/okf/cli/registry.rb +152 -15
- data/lib/okf/cli/render.rb +3 -2
- data/lib/okf/cli/search.rb +33 -2
- data/lib/okf/cli/server.rb +16 -5
- data/lib/okf/cli/stats.rb +36 -11
- data/lib/okf/cli/tags.rb +29 -7
- data/lib/okf/cli/types.rb +1 -1
- data/lib/okf/cli.rb +10 -6
- data/lib/okf/registry.rb +351 -20
- data/lib/okf/render/graph/template.html.erb +504 -103
- data/lib/okf/render/graph.rb +27 -3
- data/lib/okf/server/app.rb +74 -3
- data/lib/okf/server/hub.rb +40 -30
- data/lib/okf/skill/SKILL.md +17 -10
- 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 +3 -2
- data/lib/okf/skill/playbooks/refine.md +30 -3
- data/lib/okf/skill/playbooks/search.md +7 -7
- data/lib/okf/skill/reference/cli.md +171 -32
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +10 -0
- metadata +21 -8
|
@@ -50,7 +50,12 @@
|
|
|
50
50
|
:root{
|
|
51
51
|
--bg:#ffffff; --panel:#ffffff; --panel-2:#f6f7f8; --canvas:#f4f5f7; --ink:#1f2328; --ink-soft:#333333;
|
|
52
52
|
--muted:#63697a; --faint:#9298a4; --line:#e6e8eb; --line-2:#eef0f2;
|
|
53
|
-
|
|
53
|
+
/* --edge was #cfd4db, which is 1.38:1 against the canvas — dark mode's
|
|
54
|
+
equivalent is 1.67:1, so light was always the weaker theme and anything
|
|
55
|
+
drawn *softer* than a plain edge fell off the cliff there first. #b9c0ca
|
|
56
|
+
brings it to 1.72:1, level with dark, and the cluster box borders that
|
|
57
|
+
share the token get the same benefit. */
|
|
58
|
+
--accent:#e21e1e; --accent-ink:#c81a1a; --accent-soft:#fdecec; --edge:#b9c0ca; --ok:#1a9e5f;
|
|
54
59
|
--rail:#15171c; --rail-ink:#8b919c; --rail-ink-hi:#dfe2e7;
|
|
55
60
|
--shadow:0 1px 2px rgba(20,24,33,.05),0 8px 24px rgba(20,24,33,.07);
|
|
56
61
|
}
|
|
@@ -120,6 +125,15 @@
|
|
|
120
125
|
font:inherit;font-size:13px;padding:0 10px;outline:none;transition:border-color .15s,box-shadow .15s}
|
|
121
126
|
.field:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
|
122
127
|
.field::placeholder{color:var(--muted)}
|
|
128
|
+
/* iOS Safari zooms the page whenever a focused control is under 16px, and it
|
|
129
|
+
never zooms back out — so every `/` left the reader pinching. 16px is the
|
|
130
|
+
whole fix; `maximum-scale=1` would also stop it and is the wrong trade, since
|
|
131
|
+
it takes pinch-zoom away from everyone to spare one gesture.
|
|
132
|
+
Both conditions, because neither covers the other: a phone is narrow, and a
|
|
133
|
+
tablet in landscape is not — but it still zooms. Desktop keeps 13px. */
|
|
134
|
+
@media (max-width:768px),(pointer:coarse){
|
|
135
|
+
.field{font-size:16px}
|
|
136
|
+
}
|
|
123
137
|
.search{position:relative;display:flex;align-items:center;flex:1 1 320px;min-width:130px;max-width:520px}
|
|
124
138
|
.search svg{position:absolute;left:11px;width:15px;height:15px;stroke:var(--muted);fill:none;stroke-width:1.8;pointer-events:none}
|
|
125
139
|
.search input{width:100%;padding-left:33px;padding-right:52px}
|
|
@@ -154,6 +168,12 @@
|
|
|
154
168
|
.sb-act kbd{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10px;color:var(--faint);
|
|
155
169
|
border:1px solid var(--line);border-radius:4px;padding:1px 4px;background:var(--panel);line-height:1.3}
|
|
156
170
|
.selwrap{position:relative;display:inline-flex;flex:none}
|
|
171
|
+
/* `display:inline-flex` above outranks the [hidden] attribute's own
|
|
172
|
+
display:none, so the depth select would sit in the bar unclustered. */
|
|
173
|
+
.selwrap[hidden]{display:none}
|
|
174
|
+
/* the depth control is a single digit — sized to its content, not to the
|
|
175
|
+
layout select beside it */
|
|
176
|
+
#cluster-depth{padding-right:26px;min-width:0}
|
|
157
177
|
.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
178
|
select.field{appearance:none;-webkit-appearance:none;padding-right:30px;cursor:pointer}
|
|
159
179
|
select.field:disabled{opacity:.45;cursor:not-allowed}
|
|
@@ -164,6 +184,26 @@
|
|
|
164
184
|
.btn[aria-pressed=true]{color:var(--accent);border-color:var(--accent);background:var(--accent-soft)}
|
|
165
185
|
.btn svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
|
|
166
186
|
.btn[hidden]{display:none}
|
|
187
|
+
/* ── segmented control: one setting, every state on screen ──
|
|
188
|
+
Three .btn segments sharing one border, so the group reads as a single
|
|
189
|
+
control rather than three neighbours that happen to touch. `aria-checked`
|
|
190
|
+
carries the selection instead of `aria-pressed` — these are exclusive, and
|
|
191
|
+
a radio group is what a reader's screen reader should hear.
|
|
192
|
+
The segments are narrower than a standalone .btn (30 vs 34) so the whole
|
|
193
|
+
group lands near a single select's width instead of dwarfing the bar. */
|
|
194
|
+
.seg{display:inline-flex;flex:none;border:1px solid var(--line);border-radius:9px;background:var(--panel);overflow:hidden}
|
|
195
|
+
.seg .seg-b{width:32px;height:32px;border:0;border-radius:0;background:transparent}
|
|
196
|
+
/* Larger than a toolbar icon: these glyphs carry three beads and a span in the
|
|
197
|
+
space a normal icon uses for one shape, and at 16px the beads sank into the
|
|
198
|
+
line they sit on — the spine segment read as a bare dash. */
|
|
199
|
+
.seg .seg-b svg{width:19px;height:19px}
|
|
200
|
+
.seg .seg-b+.seg-b{border-left:1px solid var(--line)}
|
|
201
|
+
.seg .seg-b:hover{background:var(--panel-2);border-color:var(--line)}
|
|
202
|
+
.seg .seg-b[aria-checked=true]{color:var(--accent);background:var(--accent-soft)}
|
|
203
|
+
/* The focus ring goes on the segment, never on the group: outlining the whole
|
|
204
|
+
control put an accent border around all three at once, which reads as "this
|
|
205
|
+
setting is on" and competes with the fill that actually says so. */
|
|
206
|
+
.seg .seg-b:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
|
|
167
207
|
.btn.text{width:auto;display:inline-flex;align-items:center;padding:0 12px;gap:7px;font-size:13px;font-weight:500;color:var(--ink)}
|
|
168
208
|
/* `.btn.text` sets display, which outranks `.btn[hidden]` above at equal
|
|
169
209
|
specificity — so hiding a text button (e.g. #fp-graph on a log) needs its own
|
|
@@ -285,6 +325,7 @@
|
|
|
285
325
|
.ws-pill.def{color:var(--accent-ink);background:var(--accent-soft);border-color:transparent}
|
|
286
326
|
.ws-meta{display:flex;align-items:center;gap:9px;font-size:11.5px;color:var(--muted);margin-top:3px;flex-wrap:wrap}
|
|
287
327
|
.ws-slug{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--faint)}
|
|
328
|
+
.ws-where{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--faint)}
|
|
288
329
|
.ws-health.ok{color:var(--ok)} .ws-health.warn{color:var(--warn)}
|
|
289
330
|
.ws-health.error,.ws-health.missing{color:var(--accent-ink)}
|
|
290
331
|
.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 +446,7 @@
|
|
|
405
446
|
#sw-input{width:100%;box-sizing:border-box}
|
|
406
447
|
#sw-list{list-style:none;margin:10px 0 0;padding:0;max-height:46vh;overflow:auto}
|
|
407
448
|
#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}
|
|
449
|
+
#sw-list a .slug,#sw-list a .sw-where{margin-left:auto;color:var(--faint);font-size:12px}
|
|
409
450
|
#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
451
|
#sw-list .sw-grp:first-child{margin-top:0}
|
|
411
452
|
/* Under a hub a view is the sideline, so it sits at the muted weight until the
|
|
@@ -764,9 +805,15 @@
|
|
|
764
805
|
#app.controls-open #file-controls .combo{flex:1 1 44%;width:auto;min-width:130px}
|
|
765
806
|
/* Wrapping by whatever happened to fit left one icon orphaned on a line of its
|
|
766
807
|
own. The two controls carrying words take a column each, so the first line
|
|
767
|
-
is two even halves; the icon buttons then get a line to themselves
|
|
768
|
-
|
|
769
|
-
|
|
808
|
+
is two even halves; the icon buttons then get a line to themselves.
|
|
809
|
+
Centred, because that line is the only one that does not fill the width —
|
|
810
|
+
Filters and the layout select are 50% each and reach both edges, so a
|
|
811
|
+
left-aligned icon row hung off the left with a pool of dead space to its
|
|
812
|
+
right, reading as a layout accident rather than a row. (`justify-content`
|
|
813
|
+
was simply never set: the comment here used to claim the icons spread
|
|
814
|
+
across the line, and they never did.) The first line is unaffected — its
|
|
815
|
+
two items already fill it, so there is no free space to distribute. */
|
|
816
|
+
#app.controls-open #graph-controls{gap:8px;justify-content:center}
|
|
770
817
|
#app.controls-open #graph-controls>#btn-filters,
|
|
771
818
|
#app.controls-open #graph-controls>.selwrap{flex:1 1 calc(50% - 4px);min-width:0}
|
|
772
819
|
/* the select has to grow with its wrapper. Stretching only the wrapper left
|
|
@@ -891,12 +938,32 @@
|
|
|
891
938
|
</select></span>
|
|
892
939
|
<button class="btn" id="btn-fit" type="button" aria-label="Fit graph to screen" title="Fit graph to screen">
|
|
893
940
|
<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
|
|
941
|
+
<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
942
|
<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>
|
|
943
|
+
<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
944
|
<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
945
|
<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
946
|
<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">
|
|
899
947
|
<svg viewBox="0 0 24 24"><path d="M4 5h16M4 9.6h10.5M4 14.2h16M4 18.8h7.5"/></svg></button>
|
|
948
|
+
<!-- How many links to draw. Three segments, each a miniature of what it does
|
|
949
|
+
to the graph: the same three concepts in the same places every time, and
|
|
950
|
+
only the wire between them changes — an extra span over the top, a single
|
|
951
|
+
line through, nothing.
|
|
952
|
+
|
|
953
|
+
The row is not an arbitrary arrangement. Three dots on one axis joined by
|
|
954
|
+
one stroke *is* a spine, so the middle segment depicts the word it means;
|
|
955
|
+
and because the dots never move, the eye reads the difference as wire
|
|
956
|
+
added or taken away rather than as three unrelated symbols. A triangle
|
|
957
|
+
was tried first and failed exactly there — dropping one of its edges
|
|
958
|
+
changes the shape (it reads as a "7") instead of reading as less. -->
|
|
959
|
+
<div class="seg" id="links" role="radiogroup" aria-label="How many links to draw">
|
|
960
|
+
<button class="btn seg-b" type="button" role="radio" data-links="all" aria-checked="true" title="All links — every link in the bundle">
|
|
961
|
+
<svg viewBox="0 0 24 24"><path d="M4 15h16M4 15q8-12 16 0"/><circle cx="4" cy="15" r="2.2" fill="currentColor" stroke="none"/><circle cx="12" cy="15" r="2.2" fill="currentColor" stroke="none"/><circle cx="20" cy="15" r="2.2" fill="currentColor" stroke="none"/></svg></button>
|
|
962
|
+
<button class="btn seg-b" type="button" role="radio" data-links="spine" aria-checked="false" title="Spine — each concept's strongest link, so nothing floats">
|
|
963
|
+
<svg viewBox="0 0 24 24"><path d="M4 15h16"/><circle cx="4" cy="15" r="2.2" fill="currentColor" stroke="none"/><circle cx="12" cy="15" r="2.2" fill="currentColor" stroke="none"/><circle cx="20" cy="15" r="2.2" fill="currentColor" stroke="none"/></svg></button>
|
|
964
|
+
<button class="btn seg-b" type="button" role="radio" data-links="none" aria-checked="false" title="No links — a concept's own appear when you select it">
|
|
965
|
+
<svg viewBox="0 0 24 24"><circle cx="4" cy="15" r="2.2" fill="currentColor" stroke="none"/><circle cx="12" cy="15" r="2.2" fill="currentColor" stroke="none"/><circle cx="20" cy="15" r="2.2" fill="currentColor" stroke="none"/></svg></button>
|
|
966
|
+
</div>
|
|
900
967
|
<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>
|
|
901
968
|
</div>
|
|
902
969
|
</div>
|
|
@@ -916,9 +983,9 @@
|
|
|
916
983
|
<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
984
|
<div class="fbody">
|
|
918
985
|
<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,
|
|
986
|
+
<input id="filter-search" class="field" placeholder="find a type, dir, tag…" autocomplete="off" aria-label="Find a filter"></label>
|
|
920
987
|
<div class="fgroup"><h4>Types</h4><div class="fchips" id="ftypes"></div></div>
|
|
921
|
-
<div class="fgroup"><h4>
|
|
988
|
+
<div class="fgroup"><h4>Dirs</h4><div class="fchips" id="fdirs"></div></div>
|
|
922
989
|
<div class="fgroup"><h4>Tags</h4><div class="fchips" id="ftags"></div></div>
|
|
923
990
|
</div>
|
|
924
991
|
</aside>
|
|
@@ -965,7 +1032,7 @@
|
|
|
965
1032
|
<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
1033
|
<input id="cat-filter-search" class="field" placeholder="find a type, area, tag…" autocomplete="off"></label>
|
|
967
1034
|
<div class="fgroup"><h4>Types</h4><div class="fchips" id="cat-ftypes"></div></div>
|
|
968
|
-
<div class="fgroup"><h4>
|
|
1035
|
+
<div class="fgroup"><h4>Dirs</h4><div class="fchips" id="cat-fdirs"></div></div>
|
|
969
1036
|
<div class="fgroup"><h4>Tags</h4><div class="fchips" id="cat-ftags"></div></div>
|
|
970
1037
|
</div>
|
|
971
1038
|
</aside>
|
|
@@ -1008,7 +1075,7 @@
|
|
|
1008
1075
|
<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
1076
|
<div class="fbody">
|
|
1010
1077
|
<div class="fgroup"><h4>Types</h4><div class="fchips" id="tag-ftypes"></div></div>
|
|
1011
|
-
<div class="fgroup"><h4>
|
|
1078
|
+
<div class="fgroup"><h4>Dirs</h4><div class="fchips" id="tag-fdirs"></div></div>
|
|
1012
1079
|
</div>
|
|
1013
1080
|
</aside>
|
|
1014
1081
|
</section>
|
|
@@ -1020,7 +1087,7 @@
|
|
|
1020
1087
|
<div class="stat-grid" id="stat-grid"></div>
|
|
1021
1088
|
<div class="two">
|
|
1022
1089
|
<div><div class="shead"><h2>By type</h2></div><div class="bars" id="bars-type"></div></div>
|
|
1023
|
-
<div><div class="shead"><h2>By
|
|
1090
|
+
<div><div class="shead"><h2>By dir</h2></div><div class="bars" id="bars-dir"></div></div>
|
|
1024
1091
|
</div>
|
|
1025
1092
|
</div>
|
|
1026
1093
|
</section>
|
|
@@ -1098,7 +1165,7 @@
|
|
|
1098
1165
|
Mermaid (lazy) fullscreen diagram viewer
|
|
1099
1166
|
inspector drag-resize splitters
|
|
1100
1167
|
graph filters layout
|
|
1101
|
-
cluster by
|
|
1168
|
+
cluster by directory file-tree mode
|
|
1102
1169
|
the authored layer fit to screen
|
|
1103
1170
|
view switching full-text search
|
|
1104
1171
|
catalog files
|
|
@@ -1115,7 +1182,7 @@
|
|
|
1115
1182
|
1. `applyGraphFilter()` is the shared sink. The search box, the three chip
|
|
1116
1183
|
groups in the slide-over, the inspector's focus chips and cluster mode
|
|
1117
1184
|
all funnel into it, and it reads `q.graph`, `activeTypes`, `activeTags`,
|
|
1118
|
-
`
|
|
1185
|
+
`activeDirs` and `ftMatch()` together. Touching any one caller can
|
|
1119
1186
|
change what the other four produce.
|
|
1120
1187
|
2. `setView()` reaches into nearly every section — it toggles the tool
|
|
1121
1188
|
groups, seeds a view on first entry via `inited`, and has to
|
|
@@ -1125,7 +1192,8 @@
|
|
|
1125
1192
|
filled by one section and read by three or four. A stale one is
|
|
1126
1193
|
invisible until an unrelated view renders wrong.
|
|
1127
1194
|
|
|
1128
|
-
The mode flags (`clustered`, `treeMode`, `ixNodes`,
|
|
1195
|
+
The mode flags (`clustered` + `clusterDepth`, `treeMode`, `ixNodes`,
|
|
1196
|
+
`ixOnly`, `view`) are
|
|
1129
1197
|
read across boundaries too: each rebuilds the graph's elements and must
|
|
1130
1198
|
undo itself exactly, or its nodes leak into the plain view.
|
|
1131
1199
|
|
|
@@ -1134,6 +1202,11 @@
|
|
|
1134
1202
|
real page in Chromium and fails on any error the page throws.
|
|
1135
1203
|
═════════════════════════════════════════════════════════════════════════ */
|
|
1136
1204
|
const NODES=<%= nodes_json %>, EDGES=<%= edges_json %>, TYPES=<%= types_json %>, TAGS=<%= tags_json %>;
|
|
1205
|
+
/* Index-aligned with EDGES: the cut each link survives (OKF::Bundle::Skeleton).
|
|
1206
|
+
Read once, at boot, to lay the graph out on its strongest links first — which
|
|
1207
|
+
is earlier than any fetch could answer, and the whole reason it is inlined
|
|
1208
|
+
rather than served from /skeleton with the arcs. */
|
|
1209
|
+
const EDGE_CUT=<%= edge_cuts_json %>;
|
|
1137
1210
|
const NODE_ENDPOINT=<%= @node_endpoint.to_json %>, META_ENDPOINT=<%= @meta_endpoint.to_json %>;
|
|
1138
1211
|
const BUNDLE=<%= name_json %>;
|
|
1139
1212
|
const CATALOG_ENDPOINT="catalog",INDEX_ENDPOINT="index",LOG_ENDPOINT="log";
|
|
@@ -1187,7 +1260,28 @@ const esc=s=>(s==null?'':String(s)).replace(/[&<>"']/g,c=>({'&':'&','<':'<
|
|
|
1187
1260
|
const cvar=n=>getComputedStyle(root).getPropertyValue(n).trim();
|
|
1188
1261
|
const isDark=()=>root.getAttribute('data-theme')==='dark';
|
|
1189
1262
|
const reduce=()=>matchMedia('(prefers-reduced-motion:reduce)').matches;
|
|
1190
|
-
|
|
1263
|
+
/* A concept's directory — the whole path, "." at the root — mirroring the
|
|
1264
|
+
`dir` every catalog row carries. This used to be `areaOf`, the *first path
|
|
1265
|
+
segment* only: a word the OKF spec never uses, and a projection that made
|
|
1266
|
+
nested clustering impossible because everything below level one was already
|
|
1267
|
+
thrown away before the boxes were drawn. */
|
|
1268
|
+
const dirOf=id=>{const i=id.lastIndexOf('/');return i<0?'.':id.slice(0,i);};
|
|
1269
|
+
const dirDepth=d=>d==='.'?0:d.split('/').length;
|
|
1270
|
+
const dirTrunc=(d,n)=>d==='.'?'.':d.split('/').slice(0,n).join('/');
|
|
1271
|
+
const dirParent=d=>{const i=d.lastIndexOf('/');return i<0?null:d.slice(0,i);};
|
|
1272
|
+
/* The one rule `okf --dir` uses, verbatim: a directory names itself and
|
|
1273
|
+
everything beneath it. "." needs no special case — nothing starts with "./",
|
|
1274
|
+
so the root selects only what lives directly in it. */
|
|
1275
|
+
const underDir=(d,path)=>d===path||d.indexOf(path+'/')===0;
|
|
1276
|
+
/* `.` is the data; `(root)` is what a human reads. A box caption is the last
|
|
1277
|
+
segment — a full path on every nested box would bury the picture in text. */
|
|
1278
|
+
const dirLabel=d=>d==='.'?'(root)':d.split('/').pop()+'/';
|
|
1279
|
+
/* Where a bundle lives, for the rows that name it — and only where that says
|
|
1280
|
+
something its slug does not. The slug is the name (`@okf-gem`, /b/okf-gem/);
|
|
1281
|
+
the folder is a fact about it, so a row shows the fact beside the name and
|
|
1282
|
+
drops it when the two read alike. Server-side twin: Bundle::Folder.label,
|
|
1283
|
+
which is what `title` already arrived as. */
|
|
1284
|
+
const bundleWhere=b=>b.title===b.slug?'':(b.title||'');
|
|
1191
1285
|
|
|
1192
1286
|
/* ── Cytoscape ── */
|
|
1193
1287
|
function styleSheet(){const ink=cvar('--ink'),halo=cvar('--canvas'),edge=cvar('--edge');
|
|
@@ -1210,8 +1304,12 @@ function styleSheet(){const ink=cvar('--ink'),halo=cvar('--canvas'),edge=cvar('-
|
|
|
1210
1304
|
/* authorship still shows as form: a map nobody wrote is hollow and dashed */
|
|
1211
1305
|
{selector:'node.ix-syn',style:{'background-color':cvar('--faint'),'background-opacity':.2,'color':cvar('--muted'),
|
|
1212
1306
|
'border-width':1.5,'border-color':cvar('--faint'),'border-style':'dashed'}},
|
|
1213
|
-
|
|
1214
|
-
|
|
1307
|
+
/* A dash carries roughly half the ink of a solid line of the same weight, so
|
|
1308
|
+
matching a plain edge's opacity is not the same as matching its presence —
|
|
1309
|
+
these read fainter at equal numbers, and at .5 they were invisible in light
|
|
1310
|
+
mode. They sit *above* a plain edge now (.85 vs .8) to land level with it. */
|
|
1311
|
+
{selector:'edge.ixe',style:{'width':1.1,'opacity':.85,'line-style':'dashed','line-color':cvar('--edge'),'target-arrow-shape':'none'}},
|
|
1312
|
+
{selector:'edge.ixe-syn',style:{'opacity':.55}},
|
|
1215
1313
|
{selector:'edge.linkhid',style:{'display':'none'}},
|
|
1216
1314
|
{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')}},
|
|
1217
1315
|
/* ── state, last so it outranks every look above ──
|
|
@@ -1229,9 +1327,50 @@ function styleSheet(){const ink=cvar('--ink'),halo=cvar('--canvas'),edge=cvar('-
|
|
|
1229
1327
|
a floor above the fitting zoom puts the whole picture out of reach, fit
|
|
1230
1328
|
button included. relaxZoom() lowers it to follow anything bigger. */
|
|
1231
1329
|
const MIN_ZOOM=.2;
|
|
1330
|
+
/* ── the first layout runs on the strongest links only ──
|
|
1331
|
+
The boot layout is the wait on a big bundle, and it is the *edges* that make
|
|
1332
|
+
it one. Measured on 414 concepts / 1829 links: cose takes 6.25s, of which
|
|
1333
|
+
about 2.1s is the nodes and the rest is edges, near enough linearly.
|
|
1334
|
+
|
|
1335
|
+
Hiding edges does not help — it was the first thing tried, and it bought 0.5%,
|
|
1336
|
+
because the layout runs over hidden elements just the same (it does help the
|
|
1337
|
+
*paint*: a pan frame goes 20.7ms → 11.8ms, which is why the link layer still
|
|
1338
|
+
hides rather than removes — an edge it took away has to come straight back
|
|
1339
|
+
when a concept is selected). Removing them before the layout is what pays:
|
|
1340
|
+
6.25s → 3.29s at half the edges.
|
|
1341
|
+
|
|
1342
|
+
So the first layout gets `EDGE_CUT[i]===0` — each concept's single strongest
|
|
1343
|
+
link, per OKF::Bundle::Skeleton. That set is chosen rather than sampled, and
|
|
1344
|
+
the property that makes it usable is that it *touches every linked concept*,
|
|
1345
|
+
so nothing is left unplaced and the arrangement is a real one rather than a
|
|
1346
|
+
sketch to be redone. The rest arrive on the next frame and no layout re-runs,
|
|
1347
|
+
so nothing on screen moves — the reader sees the graph sooner and then sees
|
|
1348
|
+
more lines in it, never a jump.
|
|
1349
|
+
|
|
1350
|
+
Only above BOOT_MIN_EDGES, where the saving is worth the extra path at all:
|
|
1351
|
+
at 800 edges the edge share of the layout is under a second. */
|
|
1352
|
+
const BOOT_MIN_EDGES=800;
|
|
1353
|
+
/* Each edge carries its own cut, so the `spine` link setting is a comparison on
|
|
1354
|
+
data the edge already holds rather than a lookup keyed on endpoints. Same
|
|
1355
|
+
number, two readers: this split, and linkShown(). */
|
|
1356
|
+
const cutAt=(i)=>(EDGE_CUT&&EDGE_CUT.length===EDGES.length?EDGE_CUT[i]:0);
|
|
1357
|
+
const edgeEls=EDGES.map((e,i)=>({data:{source:e.source,target:e.target,cut:cutAt(i)}}));
|
|
1358
|
+
const bootSplit=(()=>{
|
|
1359
|
+
if(!EDGE_CUT||EDGE_CUT.length!==EDGES.length||EDGES.length<=BOOT_MIN_EDGES)return {first:edgeEls,rest:[]};
|
|
1360
|
+
const first=[],rest=[];
|
|
1361
|
+
edgeEls.forEach(el=>(el.data.cut===0?first:rest).push(el));
|
|
1362
|
+
return {first:first,rest:rest};})();
|
|
1232
1363
|
const cy=cytoscape({container:document.getElementById('cy'),minZoom:MIN_ZOOM,maxZoom:1.6,wheelSensitivity:.2,
|
|
1233
|
-
elements:[...NODES.map(n=>({data:{id:n.id,title:n.title,c:color[typeOf[n.id]||'Untyped']||'#64748b',sz:szOf(n.id)}})),...
|
|
1364
|
+
elements:[...NODES.map(n=>({data:{id:n.id,title:n.title,c:color[typeOf[n.id]||'Untyped']||'#64748b',sz:szOf(n.id)}})),...bootSplit.first],
|
|
1234
1365
|
style:styleSheet(),layout:{name:'<%= @layout %>',animate:false,nodeRepulsion:9000,idealEdgeLength:170,padding:40,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2,nodeDimensionsIncludeLabels:true}});
|
|
1366
|
+
/* Next frame, not next tick: the boot layout finished inside the constructor
|
|
1367
|
+
above (animate:false), so the only thing still owed is a paint, and yielding
|
|
1368
|
+
one frame is what buys the reader the early picture this whole split is for. */
|
|
1369
|
+
if(bootSplit.rest.length)requestAnimationFrame(()=>{
|
|
1370
|
+
cy.batch(()=>cy.add(bootSplit.rest.map(el=>({group:'edges',data:el.data}))));
|
|
1371
|
+
/* The late edges arrive after applyGraphFilter may already have run once, so
|
|
1372
|
+
they would come back unfiltered — and under a narrowed link layer, visible. */
|
|
1373
|
+
applyGraphFilter();});
|
|
1235
1374
|
function applyCyTheme(){cy.style(styleSheet());}
|
|
1236
1375
|
/* Debounced so the panel width transition (--side-w, .22s) fires cy.resize()
|
|
1237
1376
|
once after it settles instead of on every frame — avoids canvas flicker. */
|
|
@@ -1466,6 +1605,11 @@ function focusNode(ele,opened){if(!ele||!ele.length)return;
|
|
|
1466
1605
|
the state. (Outside cluster mode there are no :parent nodes, so this is a
|
|
1467
1606
|
no-op there.) */
|
|
1468
1607
|
cy.elements().removeClass('hl');cy.elements().not(':parent').addClass('dim');
|
|
1608
|
+
/* With the link layer narrowed the arrows are hidden, so emphasis alone would
|
|
1609
|
+
highlight a neighbourhood joined by nothing visible. Bring this node's own
|
|
1610
|
+
links back first — the selection is exactly the moment they stop being
|
|
1611
|
+
noise and become the answer to something. */
|
|
1612
|
+
linkReveal(ele);
|
|
1469
1613
|
ele.closedNeighborhood().removeClass('dim');ele.addClass('hl');
|
|
1470
1614
|
centerOn(ele,opened);}
|
|
1471
1615
|
function select(id){const ele=cy.getElementById(id);if(!ele.length)return;const opened=show(id);
|
|
@@ -1474,7 +1618,9 @@ function select(id){const ele=cy.getElementById(id);if(!ele.length)return;const
|
|
|
1474
1618
|
cy.on('tap','node',e=>{const t=e.target;
|
|
1475
1619
|
if(t.hasClass('dir')){showDir(t.id().slice(DIR.length)||'.');return focusNode(t,true);}
|
|
1476
1620
|
if(t.hasClass('ix')){showDir(t.data('dir')||'.');return focusNode(t,true);}
|
|
1477
|
-
|
|
1621
|
+
/* A box's id carries the directory verbatim — "." included — so the tap needs
|
|
1622
|
+
no unmangling of a display label back into a path. */
|
|
1623
|
+
if(t.isParent()){const id=t.id();return showDir(id.indexOf('box::')===0?id.slice(5):'.');}
|
|
1478
1624
|
select(t.id());});
|
|
1479
1625
|
/* Clear the focus: drop the dim/highlight and let the hash forget the node, so a
|
|
1480
1626
|
reload does not reselect it. Tapping empty canvas does this — but a dense graph
|
|
@@ -1486,57 +1632,82 @@ cy.on('tap',e=>{if(e.target===cy)deselect();});
|
|
|
1486
1632
|
/* Three groups, one grammar: empty means everything, a click narrows, a second
|
|
1487
1633
|
click puts it back. Types used to be the odd one out — a `hiddenTypes` set
|
|
1488
1634
|
where every type showed until you clicked one *away* — so the same chip
|
|
1489
|
-
component meant "include" under
|
|
1635
|
+
component meant "include" under Dirs and Tags and "exclude" two rows above,
|
|
1490
1636
|
and meant "include" again on the catalog's own type chips one view over. That
|
|
1491
1637
|
is a rule a reader has to learn per panel instead of once. Within a group
|
|
1492
1638
|
selections union (Service *or* Charter); across groups they intersect. */
|
|
1493
|
-
const activeTypes=new Set(), activeTags=new Set(),
|
|
1639
|
+
const activeTypes=new Set(), activeTags=new Set(), activeDirs=new Set();
|
|
1494
1640
|
const searchInput=document.getElementById('search');
|
|
1495
1641
|
const q={graph:'',catalog:'',files:'',tags:''};
|
|
1496
1642
|
function applyGraphFilter(){const s=q.graph.toLowerCase();const ids=ftMatch(q.graph);
|
|
1497
1643
|
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
1644
|
const hay=(n.data('title')+' '+t+' '+nt.join(' ')+' '+(descOf[id]||'')).toLowerCase();
|
|
1499
1645
|
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)))&&(
|
|
1646
|
+
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
1647
|
n.style('display',m?'element':'none');});
|
|
1502
|
-
/* Cluster mode draws
|
|
1503
|
-
hides every concept in
|
|
1648
|
+
/* Cluster mode draws a box around each directory's concepts. When a filter
|
|
1649
|
+
hides every concept in one, hide the now-empty box too (and bring it back
|
|
1504
1650
|
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
|
-
|
|
1651
|
+
out of the fit, now applied to what is drawn.
|
|
1652
|
+
Leaf descendants, not children: a box nests other boxes now, and an
|
|
1653
|
+
intermediate directory holding nothing directly has no children but its
|
|
1654
|
+
sub-boxes — `children()` read it as empty and took the whole branch beneath
|
|
1655
|
+
it off the canvas. One pass still does it, because the predicate is what
|
|
1656
|
+
knows about depth, not the loop. */
|
|
1657
|
+
cy.nodes(':parent').forEach(p=>p.style('display',p.descendants().some(c=>!c.isParent()&&c.style('display')!=='none')?'element':'none'));
|
|
1658
|
+
/* A loosened filter brings back nodes the layout never placed — they return at
|
|
1659
|
+
whatever coordinates the pre-cluster force layout left them at, dragging
|
|
1660
|
+
their box across the canvas. Re-tile when that happens. Narrowing never
|
|
1661
|
+
re-runs: the visible set only shrinks, and every survivor is already laid. */
|
|
1662
|
+
if(clustered&&clusterLaid&&cy.nodes(':visible').some(n=>!n.isParent()&&!clusterLaid.has(n.id())))clusterLayout();
|
|
1507
1663
|
ixVisibility();
|
|
1664
|
+
linkVisibility();
|
|
1508
1665
|
relaxZoom();
|
|
1509
1666
|
bridgeSync();}
|
|
1510
|
-
function fbadge(){const c=activeTypes.size+activeTags.size+
|
|
1667
|
+
function fbadge(){const c=activeTypes.size+activeTags.size+activeDirs.size;const b=document.getElementById('btn-filters');
|
|
1511
1668
|
b.classList.toggle('on-filter',c>0);b.querySelector('.fbadge').textContent=c;ctlBadge();syncFacets();}
|
|
1512
1669
|
/* mirror the active view's tool state onto the ⚙ toggle — when the sheet is
|
|
1513
1670
|
folded away on mobile, an active filter must still call out from the bar */
|
|
1514
1671
|
function ctlBadge(){const b=document.getElementById('btn-controls');if(!b)return;
|
|
1515
1672
|
let c=0;
|
|
1516
|
-
if(view==='graph')c=activeTypes.size+activeTags.size+
|
|
1673
|
+
if(view==='graph')c=activeTypes.size+activeTags.size+activeDirs.size;
|
|
1517
1674
|
else if(view==='files'){try{c=(fileTypeCombo.get()?1:0)+(fileTagCombo.get()?1:0);}catch(err){}}
|
|
1518
1675
|
b.classList.toggle('on-filter',c>0);b.querySelector('.fbadge').textContent=c;}
|
|
1519
|
-
const ftypes=document.getElementById('ftypes'), ftags=document.getElementById('ftags'),
|
|
1520
|
-
|
|
1521
|
-
|
|
1676
|
+
const ftypes=document.getElementById('ftypes'), ftags=document.getElementById('ftags'), fdirs=document.getElementById('fdirs');
|
|
1677
|
+
/* Every directory a concept lives in, plus each of their ancestors — so a chip
|
|
1678
|
+
exists for an intermediate directory that holds nothing itself but whose
|
|
1679
|
+
subtree does. The root is listed only when concepts actually sit there. */
|
|
1680
|
+
const graphDirs=(()=>{const s=new Set();
|
|
1681
|
+
NODES.forEach(n=>{let d=dirOf(n.id);if(d==='.'){s.add('.');return;}while(d){s.add(d);d=dirParent(d);}});
|
|
1682
|
+
return [...s].sort((a,b)=>a==='.'?-1:b==='.'?1:(a<b?-1:a>b?1:0));})();
|
|
1683
|
+
/* Counted by the same prefix rule the chip filters with, so the number on a
|
|
1684
|
+
chip is exactly how many concepts clicking it leaves — the root's count is
|
|
1685
|
+
its own, since "." is a prefix of nothing. */
|
|
1686
|
+
const dirCount={};graphDirs.forEach(d=>{dirCount[d]=NODES.filter(n=>underDir(dirOf(n.id),d)).length;});
|
|
1522
1687
|
const tagsByCount=Object.entries(TAGS).sort((a,b)=>b[1].length-a[1].length).map(e=>e[0]);
|
|
1523
1688
|
const dotFor=t=>`<span class="dot" style="background:${color[t]}"></span>`;
|
|
1524
1689
|
const toggleSet=(s,v)=>{s.has(v)?s.delete(v):s.add(v);};
|
|
1525
1690
|
/* One renderer for every filter chip group (graph, catalog and tags views):
|
|
1526
1691
|
paints the items with their current state class and wires the toggle. */
|
|
1692
|
+
/* `label` is separate from `v` for one reason: the root directory's value is
|
|
1693
|
+
"." and its label "(root)", and a chip that filtered on what it displayed
|
|
1694
|
+
would be filtering on a spelling the data never uses. */
|
|
1527
1695
|
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('');
|
|
1696
|
+
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
1697
|
el.querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>onToggle(ch.getAttribute(attr)));}
|
|
1530
1698
|
const typeItems=list=>list.map(t=>({v:t,n:(TYPES[t]||[]).length,dot:dotFor(t)}));
|
|
1531
|
-
const
|
|
1699
|
+
const dirItems=list=>list.map(d=>({v:d,label:d==='.'?'(root)':d,n:dirCount[d]}));
|
|
1532
1700
|
const tagItems=list=>list.map(t=>({v:t,n:(TAGS[t]||[]).length}));
|
|
1533
1701
|
/* The find box narrows the chips themselves; while it is empty, tags cap at the
|
|
1534
1702
|
top 40 so a busy bundle doesn't flood the panel — searching reaches ALL tags. */
|
|
1535
1703
|
const fsearch=document.getElementById('filter-search');
|
|
1704
|
+
/* The finder matches what the reader sees, so typing "root" reaches the chip
|
|
1705
|
+
labelled (root) even though its value is ".". */
|
|
1706
|
+
const dirMatch=qq=>d=>!qq||(d==='.'?'(root)':d).toLowerCase().includes(qq);
|
|
1536
1707
|
function syncFilterChips(){const qq=fsearch.value.trim().toLowerCase();const match=v=>!qq||v.toLowerCase().includes(qq);
|
|
1537
1708
|
const after=()=>{syncFilterChips();applyGraphFilter();fbadge();};
|
|
1538
1709
|
chipRow(ftypes,typeItems(types.filter(match)),'data-t',t=>activeTypes.has(t),t=>{toggleSet(activeTypes,t);after();});
|
|
1539
|
-
chipRow(
|
|
1710
|
+
chipRow(fdirs,dirItems(graphDirs.filter(dirMatch(qq))),'data-dir',d=>activeDirs.has(d),d=>{toggleSet(activeDirs,d);after();});
|
|
1540
1711
|
chipRow(ftags,tagItems(qq?tagsByCount.filter(match):tagsByCount.slice(0,40)),'data-tag',t=>activeTags.has(t),t=>{toggleSet(activeTags,t);after();});}
|
|
1541
1712
|
fsearch.oninput=syncFilterChips;
|
|
1542
1713
|
syncFilterChips();
|
|
@@ -1544,9 +1715,9 @@ syncFilterChips();
|
|
|
1544
1715
|
// so the container has resized), reusing the working fitGraph implementation.
|
|
1545
1716
|
function fitVisible(){requestAnimationFrame(()=>requestAnimationFrame(fitGraph));}
|
|
1546
1717
|
function focusGraphType(t){setView('graph');clearSets();activeTypes.add(t);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
|
|
1547
|
-
function
|
|
1718
|
+
function focusGraphDir(d){setView('graph');clearSets();activeDirs.add(d);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
|
|
1548
1719
|
function focusGraphTag(t){setView('graph');clearSets();activeTags.add(t);syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
|
|
1549
|
-
const clearSets=()=>{activeTypes.clear();activeTags.clear();
|
|
1720
|
+
const clearSets=()=>{activeTypes.clear();activeTags.clear();activeDirs.clear();};
|
|
1550
1721
|
function clearGraphFilter(){clearSets();syncFilterChips();applyGraphFilter();fbadge();fitVisible();}
|
|
1551
1722
|
/* Is the graph focused on exactly this facet and nothing else? Only then is a
|
|
1552
1723
|
second click on the chip an undo — with any other filter in play, clearing
|
|
@@ -1557,8 +1728,8 @@ function clearGraphFilter(){clearSets();syncFilterChips();applyGraphFilter();fba
|
|
|
1557
1728
|
on t" was `every other type hidden`, which a one-type bundle satisfies with
|
|
1558
1729
|
nothing hidden at all — so an unfiltered graph read as focused, lit the chip,
|
|
1559
1730
|
and spent the first click clearing nothing. */
|
|
1560
|
-
function typeFocused(t){return !activeTags.size&&!
|
|
1561
|
-
function tagFocused(t){return !activeTypes.size&&!
|
|
1731
|
+
function typeFocused(t){return !activeTags.size&&!activeDirs.size&&activeTypes.size===1&&activeTypes.has(t);}
|
|
1732
|
+
function tagFocused(t){return !activeTypes.size&&!activeDirs.size&&activeTags.size===1&&activeTags.has(t);}
|
|
1562
1733
|
/* The chips are filter indicators, so they repaint wherever the badge does —
|
|
1563
1734
|
that way a filter cleared from the slide-over un-lights the chip too. */
|
|
1564
1735
|
function syncFacets(){
|
|
@@ -1590,7 +1761,18 @@ async function ensureLayout(name){const ext=LAYOUT_EXT[name];if(!ext||loadedExt[
|
|
|
1590
1761
|
keeps a real gap between boxes in cose; avoidOverlap + spacingFactor do the
|
|
1591
1762
|
same for the geometric layouts. Extras are ignored by layouts that don't know
|
|
1592
1763
|
them. */
|
|
1593
|
-
|
|
1764
|
+
/* Past this many concepts a layout transition stops being orientation and starts
|
|
1765
|
+
being the reason the tab is unresponsive. Measured in nodes rather than edges
|
|
1766
|
+
because it is the node re-render that costs. */
|
|
1767
|
+
const ANIMATE_MAX=250;
|
|
1768
|
+
/* `'end'`, never `true`. The force layouts read `animate:true` as "render every
|
|
1769
|
+
tick of the simulation" — that is the visible bounce, and it is hundreds of
|
|
1770
|
+
full re-renders for one settle. `'end'` runs the same simulation headless and
|
|
1771
|
+
moves the nodes once, into the same final positions. Above ANIMATE_MAX even
|
|
1772
|
+
that one transition is dropped: at that size the move itself is the jank, and
|
|
1773
|
+
a graph that appears laid out beats a graph that arrives slowly. */
|
|
1774
|
+
function animateMode(){return cy.nodes().filter(n=>!n.isParent()).length>ANIMATE_MAX?false:'end';}
|
|
1775
|
+
function layoutOpts(name){const base={animate:animateMode(),padding:40,nodeOverlap:24,avoidOverlap:true,spacingFactor:1.2,nodeDimensionsIncludeLabels:true};
|
|
1594
1776
|
if(name==='fcose')return{...base,name:'fcose',quality:'default',nodeRepulsion:6500,idealEdgeLength:160,nodeSeparation:100,packComponents:true};
|
|
1595
1777
|
if(name==='dagre')return{...base,name:'dagre',rankDir:'TB',nodeSep:26,rankSep:55,edgeSep:12};
|
|
1596
1778
|
if(name==='cola')return{...base,name:'cola',maxSimulationTime:2500,edgeLength:160,nodeSpacing:8};
|
|
@@ -1600,8 +1782,72 @@ async function runLayout(name){if(await ensureLayout(name))cy.layout(layoutOpts(
|
|
|
1600
1782
|
layoutSel.onchange=e=>{if(clustered)return;runLayout(e.target.value);};
|
|
1601
1783
|
layoutSel.value='<%= @layout %>';
|
|
1602
1784
|
|
|
1603
|
-
/* ── cluster by
|
|
1604
|
-
|
|
1785
|
+
/* ── cluster by directory (nested compound parent nodes) ── */
|
|
1786
|
+
/* A cluster is a directory of concepts, and the boxes nest as the directories
|
|
1787
|
+
do. `clusterDepth` is how many levels down that nesting goes: at 1 the view
|
|
1788
|
+
is exactly the flat one this mode always drew (one box per first segment),
|
|
1789
|
+
at the bundle's maximum every concept sits in a box for its own directory.
|
|
1790
|
+
Both are mode state — turning clustering off has to undo them together, or
|
|
1791
|
+
the boxes leak into the plain graph. */
|
|
1792
|
+
let clustered=false,clusterDepth=1;const btnCluster=document.getElementById('btn-cluster');
|
|
1793
|
+
/* Which leaves the last cluster layout actually placed. The layout runs over the
|
|
1794
|
+
visible subset (see clusterLayout), so this is what tells a *loosened* filter
|
|
1795
|
+
apart from a tightened one: a node that comes back but is not in here has
|
|
1796
|
+
never been tiled. null while unclustered. */
|
|
1797
|
+
let clusterLaid=null;
|
|
1798
|
+
const depthSel=document.getElementById('cluster-depth'),depthWrap=document.getElementById('cluster-depth-wrap');
|
|
1799
|
+
const BOX='box::';
|
|
1800
|
+
/* `dir::` is taken — file-tree mode's folder nodes wear it — so the boxes get a
|
|
1801
|
+
prefix of their own rather than a shade of meaning on a shared one. */
|
|
1802
|
+
const maxDirDepth=Math.max(1,...NODES.map(n=>dirDepth(dirOf(n.id))));
|
|
1803
|
+
depthSel.innerHTML=Array.from({length:maxDirDepth},(_,i)=>`<option value="${i+1}">${i+1}</option>`).join('');
|
|
1804
|
+
depthSel.value=String(clusterDepth);
|
|
1805
|
+
/* Every box a depth calls for: each concept's directory truncated to N, plus
|
|
1806
|
+
every ancestor of that — so an intermediate directory holding no concepts
|
|
1807
|
+
directly still gets a box for its sub-boxes to sit in. The root box is added
|
|
1808
|
+
only when concepts live at the root, and never nests another one: it holds
|
|
1809
|
+
direct-root concepts and nothing else, which is what it always did.
|
|
1810
|
+
Shallowest first, because cy.add() reads the array in order and a compound
|
|
1811
|
+
child cannot name a parent that has not been added yet. */
|
|
1812
|
+
function boxesAt(depth){const set=new Set();
|
|
1813
|
+
NODES.forEach(n=>{const d=dirOf(n.id);if(d==='.'){set.add('.');return;}
|
|
1814
|
+
let t=dirTrunc(d,depth);while(t){set.add(t);t=dirParent(t);}});
|
|
1815
|
+
return [...set].sort((a,b)=>dirDepth(a)-dirDepth(b)||(a<b?-1:a>b?1:0));}
|
|
1816
|
+
/* `grabbable:false, pannable:true` is what keeps a box scenery rather than a
|
|
1817
|
+
handle — the pair, not either alone: ungrabbable stops the box moving, but the
|
|
1818
|
+
node still swallows the drag, and it is `pannable` that hands the gesture to
|
|
1819
|
+
the canvas the way empty background does. A cluster's
|
|
1820
|
+
empty interior is the largest drag target on the canvas, so while it grabbed
|
|
1821
|
+
the compound node, dragging to look around dragged the *directory* — and the
|
|
1822
|
+
bigger the cluster, the less of the view was pannable. Leaves stay grabbable:
|
|
1823
|
+
moving a concept is a real gesture, moving its folder is not. */
|
|
1824
|
+
function addBoxes(){
|
|
1825
|
+
cy.add(boxesAt(clusterDepth).map(d=>{const data={id:BOX+d,label:dirLabel(d)};
|
|
1826
|
+
const p=d==='.'?null:dirParent(d);if(p)data.parent=BOX+p;
|
|
1827
|
+
return {group:'nodes',data:data,grabbable:false,pannable:true};}));
|
|
1828
|
+
setBoxHandles();
|
|
1829
|
+
cy.batch(()=>NODES.forEach(n=>{const e=cy.getElementById(n.id);
|
|
1830
|
+
if(e.length)e.move({parent:BOX+dirTrunc(dirOf(n.id),clusterDepth)});}));}
|
|
1831
|
+
/* Alt hands a box back its handle. The two gestures are split by frequency, not
|
|
1832
|
+
by preference: looking around is constant and stays unmodified, tidying a box
|
|
1833
|
+
is occasional and takes the modifier. Alt rather than Ctrl because on macOS
|
|
1834
|
+
Ctrl+mousedown is the system secondary click, and a modifier that opens a
|
|
1835
|
+
context menu instead of dragging is not a modifier.
|
|
1836
|
+
|
|
1837
|
+
`blur` is not defensive clutter — a keyup that lands on another window never
|
|
1838
|
+
reaches us, so without it the boxes would still be handles when the reader
|
|
1839
|
+
comes back and every drag would move the wrong thing. */
|
|
1840
|
+
let altHeld=false;
|
|
1841
|
+
function setBoxHandles(){cy.nodes(':parent').forEach(n=>{
|
|
1842
|
+
if(altHeld){n.grabify();n.unpanify();}else{n.ungrabify();n.panify();}});}
|
|
1843
|
+
addEventListener('keydown',e=>{if(e.key==='Alt'&&!altHeld){altHeld=true;setBoxHandles();}});
|
|
1844
|
+
addEventListener('keyup',e=>{if(e.key==='Alt'&&altHeld){altHeld=false;setBoxHandles();}});
|
|
1845
|
+
addEventListener('blur',()=>{if(altHeld){altHeld=false;setBoxHandles();}});
|
|
1846
|
+
/* Unparent first, then take the boxes out as one collection — removing a nested
|
|
1847
|
+
parent while its children still point at it is how a teardown leaves orphans. */
|
|
1848
|
+
function removeBoxes(){cy.batch(()=>{
|
|
1849
|
+
NODES.forEach(n=>{const e=cy.getElementById(n.id);if(e.length&&e.isChild())e.move({parent:null});});
|
|
1850
|
+
cy.nodes().filter(n=>n.id().indexOf(BOX)===0).remove();});}
|
|
1605
1851
|
/* cose treats compounds as an afterthought — nodes with no links drift to
|
|
1606
1852
|
opposite corners of a huge half-empty box, and the boxes themselves sprawl —
|
|
1607
1853
|
so grouped views needed hand-tidying every time. fcose is the engine built
|
|
@@ -1610,27 +1856,48 @@ let clustered=false;const btnCluster=document.getElementById('btn-cluster');
|
|
|
1610
1856
|
captions from overlapping. Clustering therefore always uses it, with cose
|
|
1611
1857
|
kept only for when the CDN is unreachable (a saved render opened offline
|
|
1612
1858
|
still groups, just less prettily). */
|
|
1859
|
+
/* Laid out over the *visible* elements only. Not a nicety: fcose measures every
|
|
1860
|
+
node it is handed, and a nested compound graph whose nodes went `display:none`
|
|
1861
|
+
mid-run threw on a label it could no longer measure — reachable by typing a
|
|
1862
|
+
term that matches nothing while the tiling animation is still going. Handing
|
|
1863
|
+
it the visible subset also happens to be the right answer, since a hidden
|
|
1864
|
+
concept has no business influencing where the visible ones land. Nothing
|
|
1865
|
+
visible means nothing to lay out. */
|
|
1613
1866
|
function clusterLayout(){ensureLayout('fcose').then(ok=>{if(!clustered)return;
|
|
1614
|
-
cy.
|
|
1867
|
+
const eles=cy.elements(':visible');
|
|
1868
|
+
/* Recorded before the early return, not after: "nothing was visible" and
|
|
1869
|
+
"nothing has been laid out" are the same fact, and the empty set is what
|
|
1870
|
+
makes clearing that filter re-tile everything rather than nothing. */
|
|
1871
|
+
clusterLaid=new Set(eles.nodes().filter(n=>!n.isParent()).map(n=>n.id()));
|
|
1872
|
+
if(!eles.nodes().nonempty())return;
|
|
1873
|
+
cy.layout(ok?{name:'fcose',eles:eles,quality:'proof',animate:animateMode(),padding:40,nodeRepulsion:6500,idealEdgeLength:90,
|
|
1615
1874
|
nodeSeparation:100,nestingFactor:.1,gravityCompound:1.5,tile:true,tilingPaddingVertical:14,
|
|
1616
1875
|
tilingPaddingHorizontal:14,packComponents:true,nodeDimensionsIncludeLabels:true}
|
|
1617
|
-
:{name:'cose',animate:
|
|
1876
|
+
:{name:'cose',eles:eles,animate:animateMode(),padding:40,nodeRepulsion:8000,idealEdgeLength:70,nodeOverlap:24,nestingFactor:1.2,gravity:.7}).run();});}
|
|
1618
1877
|
function setClustered(on){if(on===clustered)return;if(on&&treeMode)setTree(false);
|
|
1619
1878
|
clustered=on;btnCluster.setAttribute('aria-pressed',String(on));layoutSel.disabled=on;
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1879
|
+
/* A flat bundle has one level and therefore no choice to offer — the control
|
|
1880
|
+
stays away rather than presenting a select with a single option. */
|
|
1881
|
+
depthWrap.hidden=!(on&&maxDirDepth>1);
|
|
1882
|
+
clusterLaid=null;
|
|
1883
|
+
if(on){
|
|
1884
|
+
addBoxes();
|
|
1885
|
+
/* A box is made for every directory; if a search or filter was already
|
|
1886
|
+
narrowing the graph, the ones it emptied would cluster as phantom boxes.
|
|
1887
|
+
Re-apply the filter now — before the layout tiles them — so empty boxes
|
|
1888
|
+
never get drawn. (Filtering while already clustered runs applyGraphFilter
|
|
1889
|
+
on its own.) */
|
|
1627
1890
|
applyGraphFilter();
|
|
1628
1891
|
clusterLayout();
|
|
1629
1892
|
}else{
|
|
1630
|
-
|
|
1631
|
-
cy.nodes().filter(n=>n.id().indexOf('area::')===0).remove();
|
|
1893
|
+
removeBoxes();
|
|
1632
1894
|
runLayout(layoutSel.value);}}
|
|
1633
1895
|
btnCluster.onclick=()=>setClustered(!clustered);
|
|
1896
|
+
/* Changing depth rebuilds rather than re-parents: the box *set* changes with it
|
|
1897
|
+
(depth 2 adds boxes depth 1 never had), so tearing down and building again is
|
|
1898
|
+
both simpler and the only version with one code path to get right. */
|
|
1899
|
+
depthSel.onchange=()=>{clusterDepth=+depthSel.value;if(!clustered)return;
|
|
1900
|
+
removeBoxes();addBoxes();applyGraphFilter();clusterLayout();};
|
|
1634
1901
|
|
|
1635
1902
|
/* ── file-tree mode: folders become nodes and the only edges are folder→child,
|
|
1636
1903
|
so the picture is the acyclic files-and-folders tree, not the link graph. ── */
|
|
@@ -1724,6 +1991,104 @@ function openMapInGraph(dir){setView('graph');showDir(dir);
|
|
|
1724
1991
|
chose survives, and the map is emphasised exactly as a concept would be. */
|
|
1725
1992
|
setIxNodes(true).then(()=>focusNode(cy.getElementById(IX+dir),true));}
|
|
1726
1993
|
|
|
1994
|
+
/* ── the link layer: how many arrows to draw ─────────────────────────────────
|
|
1995
|
+
A dense bundle is unreadable because of its *arrows*, not its dots. Measured
|
|
1996
|
+
on a 47-concept bundle: 227 links over 47 concepts, three quarters of them
|
|
1997
|
+
crossing a directory boundary, at an average degree of 9.7 — every dot in a
|
|
1998
|
+
thicket, and no amount of moving them apart fixes that.
|
|
1999
|
+
|
|
2000
|
+
Three settings, because there are three honest amounts of wiring:
|
|
2001
|
+
|
|
2002
|
+
all the graph as authored. 227 arrows.
|
|
2003
|
+
spine each concept's single strongest link — the backbone Ruby already
|
|
2004
|
+
computed (OKF::Bundle::Skeleton, the `keep_at === 0` set, inlined
|
|
2005
|
+
as EDGE_CUT). 50 arrows on that bundle, 476 of 1829 on a
|
|
2006
|
+
414-concept one: about one line per concept, which is tree
|
|
2007
|
+
density. It is chosen rather than sampled, and the property that
|
|
2008
|
+
earns it the name is that it *touches every linked concept*, so
|
|
2009
|
+
nothing is left floating.
|
|
2010
|
+
none no arrows at all. The cleanest picture and the fastest to paint.
|
|
2011
|
+
|
|
2012
|
+
In every setting, selecting a concept reveals that concept's own links in
|
|
2013
|
+
full. The wiring was never the answer to a standing question — "what is in
|
|
2014
|
+
this bundle and how is it organised" is what a reader arrives with, and 227
|
|
2015
|
+
lines were drowning it — but it is exactly the answer to "what does *this*
|
|
2016
|
+
connect to", asked one concept at a time.
|
|
2017
|
+
|
|
2018
|
+
This layer only ever hides edges. It composes with cluster, file-tree and the
|
|
2019
|
+
index layer rather than excluding them, which is the whole point: `--map` is
|
|
2020
|
+
nothing but this set to `none` with the boxes on, and other combinations are
|
|
2021
|
+
just as reachable. */
|
|
2022
|
+
/* The layer opens on `all`, unless the graph is dense enough that its arrows are
|
|
2023
|
+
a thicket — then it opens on its spine (each concept's strongest link, the
|
|
2024
|
+
backbone the boot split already lays out on). Density is undirected degree,
|
|
2025
|
+
the measure this whole read is written in: the bundle that drove it ran 9.7, a
|
|
2026
|
+
tree runs about 2, and THICKET_DEGREE sits between so a graph a reader can
|
|
2027
|
+
already take in is left on `all`. Decided *here*, before the cytoscape() boot
|
|
2028
|
+
frame reads linkMode, so a split bundle lays out on its spine and reveals it
|
|
2029
|
+
without ever flashing the full set. `--map` overrides this to `none` below —
|
|
2030
|
+
an explicit request outranks the heuristic. */
|
|
2031
|
+
const THICKET_DEGREE=6;
|
|
2032
|
+
const denseGraph=!!EDGE_CUT&&EDGE_CUT.length===EDGES.length&&NODES.length>0&&(2*EDGES.length/NODES.length)>=THICKET_DEGREE;
|
|
2033
|
+
let linkMode=(denseGraph&&!<%= map_json %>)?'spine':'all';
|
|
2034
|
+
const linksSeg=document.getElementById('links');
|
|
2035
|
+
const linkBtns=[ ...linksSeg.querySelectorAll('[data-links]') ];
|
|
2036
|
+
/* The markup checks `all`; if density opened us elsewhere, move the check to
|
|
2037
|
+
match before the reader sees it. */
|
|
2038
|
+
if(linkMode!=='all')linkBtns.forEach(b=>b.setAttribute('aria-checked',String(b.dataset.links===linkMode)));
|
|
2039
|
+
|
|
2040
|
+
/* Every edge that is a *link*. The structural ones (`tree` in file-tree mode,
|
|
2041
|
+
`ixe` under the index layer) are somebody else's picture, never this one's
|
|
2042
|
+
noise, so they are never touched. */
|
|
2043
|
+
const linkEdges=()=>cy.edges().not('.tree').not('.ixe');
|
|
2044
|
+
/* Which edges this setting draws. `cut` rides on each edge's own data (see the
|
|
2045
|
+
cytoscape() call), so the spine is a comparison and never a lookup. */
|
|
2046
|
+
const linkShown=(e)=>linkMode==='all'||(linkMode==='spine'&&e.data('cut')===0);
|
|
2047
|
+
function linkVisibility(){if(treeMode)return;
|
|
2048
|
+
linkEdges().forEach(e=>e.toggleClass('linkhid',!linkShown(e)));}
|
|
2049
|
+
/* The selected concept's arrows, in full, whatever the setting. Called from
|
|
2050
|
+
focusNode so it rides the gesture the reader already makes rather than
|
|
2051
|
+
adding one of its own. */
|
|
2052
|
+
function linkReveal(ele){if(linkMode==='all')return;
|
|
2053
|
+
linkVisibility();
|
|
2054
|
+
if(ele&&ele.length)ele.connectedEdges().not('.tree').not('.ixe').removeClass('linkhid');}
|
|
2055
|
+
|
|
2056
|
+
function setLinks(mode){if(mode===linkMode)return;
|
|
2057
|
+
linkMode=mode;
|
|
2058
|
+
linkBtns.forEach(b=>b.setAttribute('aria-checked',String(b.dataset.links===mode)));
|
|
2059
|
+
if(mode==='all')cy.edges().removeClass('linkhid');else linkVisibility();}
|
|
2060
|
+
linkBtns.forEach(b=>{b.onclick=()=>setLinks(b.dataset.links);});
|
|
2061
|
+
/* Arrow keys move within a radio group — that is the contract `role=radiogroup`
|
|
2062
|
+
makes, and a group that only answers to clicks has made a promise it does not
|
|
2063
|
+
keep. Tab still enters and leaves the group as one stop. */
|
|
2064
|
+
linksSeg.onkeydown=e=>{const i=linkBtns.indexOf(document.activeElement);if(i<0)return;
|
|
2065
|
+
const d=e.key==='ArrowRight'||e.key==='ArrowDown'?1:e.key==='ArrowLeft'||e.key==='ArrowUp'?-1:0;
|
|
2066
|
+
if(!d)return;
|
|
2067
|
+
e.preventDefault();
|
|
2068
|
+
const next=linkBtns[(i+d+linkBtns.length)%linkBtns.length];
|
|
2069
|
+
next.focus();setLinks(next.dataset.links);};
|
|
2070
|
+
/* `okf server --map` / `okf render --map` open here: no arrows, and the
|
|
2071
|
+
directories boxed. Not a mode of its own — a *starting point* made of two
|
|
2072
|
+
ordinary controls, both of which the reader can move afterwards. Either half
|
|
2073
|
+
alone is half a picture: arrows off without boxes leaves a scatter of dots
|
|
2074
|
+
with nothing to read them against, and boxes with every arrow is the thicket
|
|
2075
|
+
this began with.
|
|
2076
|
+
|
|
2077
|
+
Deferred a frame, and not for tidiness: setClustered reaches ensureLayout and
|
|
2078
|
+
state declared elsewhere in this script. An inline call hit a binding in its
|
|
2079
|
+
temporal dead zone once already, and the ReferenceError took every top-level
|
|
2080
|
+
statement after this line with it — the page came up looking almost right,
|
|
2081
|
+
with the palette, the keyboard map and the boot fit all silently unbound. A
|
|
2082
|
+
frame is enough: the script has finished by then. */
|
|
2083
|
+
/* A dense graph opened on its spine at boot (see linkMode above); the hiding
|
|
2084
|
+
itself still has to run once. A *split* bundle got it for free — the boot
|
|
2085
|
+
rAF's applyGraphFilter already ran linkVisibility under the spine mode. A
|
|
2086
|
+
bundle under the split floor did not, so apply it here, on the same deferred
|
|
2087
|
+
frame and for the same TDZ reason. --map wins and needs no separate spine
|
|
2088
|
+
pass: `none` hides strictly more. */
|
|
2089
|
+
if(<%= map_json %>)requestAnimationFrame(()=>{setLinks('none');setClustered(true);});
|
|
2090
|
+
else if(denseGraph)requestAnimationFrame(linkVisibility);
|
|
2091
|
+
|
|
1727
2092
|
/* ── fit to screen ── */
|
|
1728
2093
|
// cy.animate({fit:…}) is a no-op in this Cytoscape build, so compute the target
|
|
1729
2094
|
// zoom + pan and animate those (which do work): fits the visible nodes, re-centered.
|
|
@@ -1731,10 +2096,12 @@ function openMapInGraph(dir){setView('graph');showDir(dir);
|
|
|
1731
2096
|
visible leaves plus the area boxes drawn around them. A compound parent is
|
|
1732
2097
|
26px of padding wider than its children on every side and carries its label
|
|
1733
2098
|
above that, so fitting to the children alone crops the boxes and their names
|
|
1734
|
-
off the edges. A parent
|
|
1735
|
-
|
|
2099
|
+
off the edges. A parent with no visible *leaf* descendant is left out too — it
|
|
2100
|
+
collapses to a stale box that would inflate the fit, and leaves are the test
|
|
2101
|
+
because a box nests other boxes now. */
|
|
1736
2102
|
function fitBox(){const vis=cy.nodes(':visible');
|
|
1737
|
-
const eles=vis.filter(n=>!n.isParent())
|
|
2103
|
+
const eles=vis.filter(n=>!n.isParent())
|
|
2104
|
+
.union(vis.filter(n=>n.isParent()&&n.descendants().some(c=>!c.isParent()&&c.style('display')!=='none')));
|
|
1738
2105
|
const use=eles.length?eles:cy.nodes();
|
|
1739
2106
|
return use.length?use.boundingBox():null;}
|
|
1740
2107
|
|
|
@@ -1788,17 +2155,19 @@ function setView(v){if(v===view)return;view=v;document.getElementById('app').set
|
|
|
1788
2155
|
/* the count and the dead-end offer belong to the view's own query, so they
|
|
1789
2156
|
re-decide on every switch — including into a view that has no box at all */
|
|
1790
2157
|
bridgeSync();}
|
|
1791
|
-
/*
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
2158
|
+
/* Which rail item the page is standing on — the view, except where two rail
|
|
2159
|
+
items share one. Index is a shortcut into Files that opens the root map, so
|
|
2160
|
+
while that map is what is open the reader is standing on Index and the rail
|
|
2161
|
+
has to say so; anything else in Files, a *nested* index.md included, is
|
|
2162
|
+
Files. Returning `view` flat lit Files on the one screen the reader reached by
|
|
2163
|
+
asking for Index, which reads as the click having gone somewhere else. */
|
|
2164
|
+
const ROOT_INDEX='index.md';
|
|
2165
|
+
function activeRail(){return view==='files'&&fileSel===ROOT_INDEX?'index':view;}
|
|
1797
2166
|
/* And everything that has to *go* somewhere clicks the rail item rather than
|
|
1798
|
-
calling setView:
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
2167
|
+
calling setView: "index" is not a view, so setView alone lands on one that
|
|
2168
|
+
does not exist, and even for Files it would arrive without opening the map
|
|
2169
|
+
that makes it Index. The rail button already gets this right; this is how the
|
|
2170
|
+
keyboard and the palette borrow it instead of drifting from it. */
|
|
1802
2171
|
function goRail(v){const b=document.querySelector('.rail-item[data-view="'+v+'"]');if(b)b.click();}
|
|
1803
2172
|
function railActive(){const v=activeRail();
|
|
1804
2173
|
document.querySelectorAll('.rail-item').forEach(b=>b.classList.toggle('active',b.dataset.view===v));}
|
|
@@ -1873,38 +2242,38 @@ let CATALOG=null;
|
|
|
1873
2242
|
otherwise) and fills descOf on the way, so even the pre-index substring
|
|
1874
2243
|
fallback can match a graph node by its leaf description. */
|
|
1875
2244
|
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(),
|
|
2245
|
+
const catActiveTypes=new Set(), catActiveDirs=new Set(), catActiveTags=new Set();
|
|
1877
2246
|
const catByCount=types.slice().sort((a,b)=>(TYPES[b]||[]).length-(TYPES[a]||[]).length);
|
|
1878
2247
|
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
2248
|
function syncCatChips(){document.querySelectorAll('#cat-types .chip').forEach(c=>c.classList.toggle('on',catActiveTypes.has(c.getAttribute('data-t'))));
|
|
1880
|
-
const n=catActiveTypes.size+
|
|
2249
|
+
const n=catActiveTypes.size+catActiveDirs.size+catActiveTags.size;
|
|
1881
2250
|
const b=document.getElementById('cat-filters-btn');b.classList.toggle('on-filter',n>0);b.querySelector('.fbadge').textContent=n;}
|
|
1882
2251
|
function catToggle(t){toggleSet(catActiveTypes,t);renderCatFilters();renderCatalog();}
|
|
1883
2252
|
function wireCatChips(el){el.querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>catToggle(ch.getAttribute('data-t')));}
|
|
1884
|
-
/* The slide-over mirrors the graph filters — Types,
|
|
2253
|
+
/* The slide-over mirrors the graph filters — Types, Dirs, Tags — with one find
|
|
1885
2254
|
box narrowing the chips of all three groups (tags cap at 40 until searched). */
|
|
1886
2255
|
function renderCatFilters(){const qq=(document.getElementById('cat-filter-search').value||'').trim().toLowerCase();
|
|
1887
2256
|
const match=v=>!qq||v.toLowerCase().includes(qq);
|
|
1888
2257
|
const after=()=>{renderCatFilters();renderCatalog();};
|
|
1889
2258
|
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-
|
|
2259
|
+
chipRow(document.getElementById('cat-fdirs'),dirItems(graphDirs.filter(dirMatch(qq))),'data-dir',d=>catActiveDirs.has(d),d=>{toggleSet(catActiveDirs,d);after();});
|
|
1891
2260
|
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
2261
|
syncCatChips();}
|
|
1893
2262
|
function initCatalog(){inited.catalog=true;
|
|
1894
2263
|
// Only the five most common types get inline chips; everything else — types,
|
|
1895
|
-
//
|
|
2264
|
+
// dirs, tags — lives in the searchable slide-over, so a bundle with many
|
|
1896
2265
|
// values doesn't flood the header.
|
|
1897
2266
|
const quick=document.getElementById('cat-types');quick.innerHTML=catByCount.slice(0,5).map(catChip).join('');wireCatChips(quick);
|
|
1898
2267
|
renderCatFilters();
|
|
1899
2268
|
const cf=document.getElementById('cat-filters');
|
|
1900
2269
|
document.getElementById('cat-filters-btn').onclick=()=>cf.classList.toggle('open');
|
|
1901
2270
|
document.getElementById('cat-filters-close').onclick=()=>cf.classList.remove('open');
|
|
1902
|
-
document.getElementById('cat-filters-reset').onclick=()=>{catActiveTypes.clear();
|
|
2271
|
+
document.getElementById('cat-filters-reset').onclick=()=>{catActiveTypes.clear();catActiveDirs.clear();catActiveTags.clear();renderCatFilters();renderCatalog();};
|
|
1903
2272
|
document.getElementById('cat-filter-search').oninput=renderCatFilters;
|
|
1904
2273
|
getCatalog().then(renderCatalog);}
|
|
1905
2274
|
function renderCatalog(){if(!CATALOG)return;getCatalog().then(list=>{const s=(q.catalog||'').toLowerCase();const ids=ftMatch(q.catalog);
|
|
1906
2275
|
const rows=list.filter(c=>{if(catActiveTypes.size&&!catActiveTypes.has(c.type))return false;
|
|
1907
|
-
if(
|
|
2276
|
+
if(catActiveDirs.size&&![...catActiveDirs].some(d=>underDir(dirOf(c.id),d)))return false;
|
|
1908
2277
|
if(catActiveTags.size&&!c.tags.some(t=>catActiveTags.has(t)))return false;
|
|
1909
2278
|
if(ids)return ids.has(c.id);
|
|
1910
2279
|
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 +2475,7 @@ function openFile(id,list){fileSel=id;
|
|
|
2106
2475
|
if(ixOnly)setIxOnly(false);
|
|
2107
2476
|
const c=(list||[]).find(x=>x.id===id)||byId[id]||{};
|
|
2108
2477
|
document.querySelectorAll('.file').forEach(b=>b.classList.toggle('sel',b.dataset.id===id));
|
|
2478
|
+
railActive(); /* the rail reads the open file to tell Index from Files */
|
|
2109
2479
|
const ty=c.type||typeOf[id]||'Untyped';const cc=color[ty]||'#64748b';
|
|
2110
2480
|
document.getElementById('fp-head').hidden=false;
|
|
2111
2481
|
document.getElementById('fp-type').innerHTML=`<span class="dot" style="background:${cc}"></span>${esc(ty)}`;
|
|
@@ -2118,6 +2488,7 @@ function openFile(id,list){fileSel=id;
|
|
|
2118
2488
|
log fetched fresh on every click so a just-appended entry shows. */
|
|
2119
2489
|
function openReserved(kind,path){fileSel=path;
|
|
2120
2490
|
document.querySelectorAll('.file').forEach(b=>b.classList.toggle('sel',b.dataset.path===path));
|
|
2491
|
+
railActive(); /* the rail reads the open file to tell Index from Files */
|
|
2121
2492
|
document.getElementById('fp-head').hidden=false;
|
|
2122
2493
|
document.getElementById('fp-title').textContent=path;
|
|
2123
2494
|
const d=dirOfPath(path);
|
|
@@ -2139,21 +2510,21 @@ function openReserved(kind,path){fileSel=path;
|
|
|
2139
2510
|
LOGS=null;getLogs().then(logs=>{const l=logs.find(x=>x.path===path);done((l&&l.content)||'');});}}
|
|
2140
2511
|
|
|
2141
2512
|
/* ── tags ── */
|
|
2142
|
-
const tagSel=new Set(), tagTypes=new Set(),
|
|
2143
|
-
/* Does this concept survive the tags view's type/
|
|
2513
|
+
const tagSel=new Set(), tagTypes=new Set(), tagDirs=new Set();
|
|
2514
|
+
/* Does this concept survive the tags view's type/dir filters? Tags are counted
|
|
2144
2515
|
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'))&&(
|
|
2516
|
+
function tagMatch(id){return (tagTypes.size===0||tagTypes.has(typeOf[id]||'Untyped'))&&(tagDirs.size===0||[...tagDirs].some(d=>underDir(dirOf(id),d)));}
|
|
2146
2517
|
function renderTagChips(){
|
|
2147
2518
|
const after=()=>{renderTagChips();renderCloud();};
|
|
2148
2519
|
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+
|
|
2520
|
+
chipRow(document.getElementById('tag-fdirs'),dirItems(graphDirs),'data-dir',d=>tagDirs.has(d),d=>{toggleSet(tagDirs,d);after();});
|
|
2521
|
+
const n=tagTypes.size+tagDirs.size;const b=document.getElementById('tag-filters-btn');
|
|
2151
2522
|
b.classList.toggle('on-filter',n>0);b.querySelector('.fbadge').textContent=n;}
|
|
2152
2523
|
function initTags(){inited.tags=true;
|
|
2153
2524
|
const tf=document.getElementById('tag-filters');
|
|
2154
2525
|
document.getElementById('tag-filters-btn').onclick=()=>tf.classList.toggle('open');
|
|
2155
2526
|
document.getElementById('tag-filters-close').onclick=()=>tf.classList.remove('open');
|
|
2156
|
-
document.getElementById('tag-filters-reset').onclick=()=>{tagTypes.clear();
|
|
2527
|
+
document.getElementById('tag-filters-reset').onclick=()=>{tagTypes.clear();tagDirs.clear();renderTagChips();renderCloud();};
|
|
2157
2528
|
renderTagChips();renderCloud();}
|
|
2158
2529
|
function renderCloud(){const s=(q.tags||'').toLowerCase();
|
|
2159
2530
|
// Uniform chip size, ordered by frequency (descending); the background tint scales
|
|
@@ -2183,19 +2554,23 @@ function countUp(el,to){if(reduce()||to<=8){el.textContent=to;return;}let start=
|
|
|
2183
2554
|
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
2555
|
requestAnimationFrame(step);}
|
|
2185
2556
|
function initStats(){inited.stats=true;
|
|
2186
|
-
const
|
|
2187
|
-
const stats=[['Concepts',NODES.length],['
|
|
2557
|
+
const dirs=new Set(NODES.map(n=>dirOf(n.id)));
|
|
2558
|
+
const stats=[['Concepts',NODES.length],['Dirs',dirs.size],['Concept types',types.length],['Cross-links',EDGES.length],['Distinct tags',Object.keys(TAGS).length]];
|
|
2188
2559
|
const g=document.getElementById('stat-grid');
|
|
2189
2560
|
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
2561
|
g.querySelectorAll('.n').forEach(el=>countUp(el,+el.dataset.to));
|
|
2191
2562
|
const byType=Object.entries(TYPES).map(([t,ids])=>[t,ids.length]).sort((a,b)=>b[1]-a[1]);
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2563
|
+
/* Direct counts, keyed by the whole path — the same cut `okf stats --json`
|
|
2564
|
+
emits as `by_dir`, so the panel and the CLI answer alike. The bar's value is
|
|
2565
|
+
the directory; only its label is softened to (root). */
|
|
2566
|
+
const dirCnt={};NODES.forEach(n=>{const d=dirOf(n.id);dirCnt[d]=(dirCnt[d]||0)+1;});
|
|
2567
|
+
const byDir=Object.entries(dirCnt).sort((a,b)=>b[1]-a[1]);
|
|
2568
|
+
renderBars('bars-type',byType,t=>color[t],'type');renderBars('bars-dir',byDir,()=>cvar('--accent'),'dir');}
|
|
2195
2569
|
function renderBars(id,rows,colFn,kind){const max=Math.max(1,...rows.map(r=>r[1]));const box=document.getElementById(id);
|
|
2570
|
+
const label=l=>kind==='dir'&&l==='.'?'(root)':l;
|
|
2196
2571
|
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):
|
|
2572
|
+
`<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('');
|
|
2573
|
+
box.querySelectorAll('.bar').forEach(b=>{const go=()=>{const val=b.dataset.val;kind==='type'?focusGraphType(val):focusGraphDir(val);};
|
|
2199
2574
|
b.onclick=go;b.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();go();}};});
|
|
2200
2575
|
requestAnimationFrame(()=>box.querySelectorAll('.bf').forEach(f=>f.style.transform=`scaleX(${f.dataset.w})`));}
|
|
2201
2576
|
|
|
@@ -2287,10 +2662,15 @@ var openPalette=null;
|
|
|
2287
2662
|
and layout a bundle switch preserves are exactly what a node has to override.
|
|
2288
2663
|
A hit in *this* bundle is not a navigation at all; go() selects it in place
|
|
2289
2664
|
and this href only exists for ⌘-click, where a second tab is the point. */
|
|
2290
|
-
|
|
2665
|
+
/* Three cases, not two. A hub row names another bundle and is addressed
|
|
2666
|
+
relative to its mount; a hub row for *this* bundle stays put; and a
|
|
2667
|
+
standalone row carries no slug at all, because one bundle has none to carry.
|
|
2668
|
+
Folding that third case into the first built '../undefined/' — a 404 on
|
|
2669
|
+
every result the palette returned. */
|
|
2670
|
+
const hitHref=c=>(c.slug==null||c.slug===SELF_SLUG?'':'../'+encodeURIComponent(c.slug)+'/')+'?select='+encodeURIComponent(c.id);
|
|
2291
2671
|
const hitHtml=c=>`<span class="hit-top"><span class="hit-t">${hilite(c.title||c.id,hitTerms)}</span>`+
|
|
2292
2672
|
(c.type?`<span class="hit-type">${esc(c.type)}</span>`:'')+
|
|
2293
|
-
|
|
2673
|
+
(c.slug==null?'':`<span class="slug">${esc(c.slug)}</span>`)+`</span>`+
|
|
2294
2674
|
(c.snippet?`<span class="hit-s">${hilite(c.snippet,hitTerms)}</span>`:'');
|
|
2295
2675
|
/* Bundles lead, and on an empty box they are the only thing here: switching
|
|
2296
2676
|
bundles is what the palette is for, and six views above them would push the
|
|
@@ -2312,11 +2692,17 @@ var openPalette=null;
|
|
|
2312
2692
|
let h='';
|
|
2313
2693
|
if(showB){
|
|
2314
2694
|
if(heads)h+=grp('Bundles');
|
|
2315
|
-
|
|
2695
|
+
/* The you-are-here row wears the same name as the rows under it — `@slug`
|
|
2696
|
+
where there is one to wear. A standalone page has none, and falls back to
|
|
2697
|
+
the header's own wording rather than inventing a ref that addresses
|
|
2698
|
+
nothing. */
|
|
2699
|
+
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
2700
|
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
|
-
|
|
2701
|
+
else h+=bs.map((b,i)=>{const where=bundleWhere(b);
|
|
2702
|
+
return opt(i,{
|
|
2703
|
+
attrs:`href="${esc(target(b.path))}" data-path="${esc(b.path)}"`,
|
|
2704
|
+
html:`<span>@${esc(b.slug)}${b.default?'<span class="sw-def">default</span>':''}</span>`+
|
|
2705
|
+
(where?`<span class="sw-where">${esc(where)}</span>`:'')});}).join('');
|
|
2320
2706
|
}
|
|
2321
2707
|
if(vs.length){
|
|
2322
2708
|
if(heads)h+=grp('Views');
|
|
@@ -2329,7 +2715,7 @@ var openPalette=null;
|
|
|
2329
2715
|
if(heads)h+=grp('Concepts');
|
|
2330
2716
|
h+=cs.map((c,i)=>opt(bs.length+vs.length+i,{
|
|
2331
2717
|
cls:' sw-hit',
|
|
2332
|
-
attrs:`href="${esc(hitHref(c))}" data-hit="${esc(c.id)}" data-slug="${esc(c.slug)}"`,
|
|
2718
|
+
attrs:`href="${esc(hitHref(c))}" data-hit="${esc(c.id)}" data-slug="${esc(c.slug==null?'':c.slug)}"`,
|
|
2333
2719
|
html:hitHtml(c)})).join('');
|
|
2334
2720
|
/* The cap is reported, never silent: a palette that shows 50 of 214 and says
|
|
2335
2721
|
nothing has told the reader the answer is complete. */
|
|
@@ -2363,10 +2749,15 @@ var openPalette=null;
|
|
|
2363
2749
|
const id=a.dataset.hit;
|
|
2364
2750
|
/* A concept in this bundle is already loaded — selecting it in place keeps the
|
|
2365
2751
|
camera, the filters and the layout the reader built. Only a hit in another
|
|
2366
|
-
bundle is worth a page load.
|
|
2752
|
+
bundle is worth a page load.
|
|
2753
|
+
Empty slug means a standalone server: one bundle, so every hit is this one.
|
|
2754
|
+
It reads as '' rather than SELF_SLUG's null because the row simply has no
|
|
2755
|
+
slug key, and comparing the two directly sent every standalone hit down the
|
|
2756
|
+
page-load branch — the whole index reloaded to reach a node already on
|
|
2757
|
+
screen. */
|
|
2367
2758
|
if(id!==undefined){const t=a.getAttribute('href');
|
|
2368
2759
|
if(newTab){window.open(t,'_blank');close();return;}
|
|
2369
|
-
if(a.dataset.slug===SELF_SLUG){close();goToGraph(id);return;}
|
|
2760
|
+
if(a.dataset.slug===''||a.dataset.slug===SELF_SLUG){close();goToGraph(id);return;}
|
|
2370
2761
|
location.href=t;return;}
|
|
2371
2762
|
const p=a.dataset.path;if(!p)return;const t=target(p);
|
|
2372
2763
|
if(newTab){window.open(t,'_blank');close();}else{location.href=t;}}
|
|
@@ -2412,9 +2803,13 @@ var openPalette=null;
|
|
|
2412
2803
|
is switching bundles. Standalone has neither, so there the view is the job.
|
|
2413
2804
|
The ⇄ button keeps the bundle wording in every mode: it draws a swap, and a
|
|
2414
2805
|
control whose tooltip disagrees with its icon teaches the wrong thing. */
|
|
2415
|
-
|
|
2806
|
+
/* FINDS no longer implies HUB: a standalone server answers /search over the one
|
|
2807
|
+
bundle it was given, so the copy has to name what is actually on offer rather
|
|
2808
|
+
than assume a set is behind it. Four states, because both bits are real. */
|
|
2809
|
+
const label=FINDS?(HUB?'Search all bundles':'Search concepts'):HUB?'Switch bundle':'Jump to a view';
|
|
2416
2810
|
const btnLabel=HUB?'Switch bundle':label;
|
|
2417
|
-
input.placeholder=FINDS?'search concepts, switch bundle, or a view…':
|
|
2811
|
+
input.placeholder=FINDS?(HUB?'search concepts, switch bundle, or a view…':'search concepts, or a view…')
|
|
2812
|
+
:HUB?'switch bundle, or type a view…':'jump to a view…';
|
|
2418
2813
|
input.setAttribute('aria-label',label);
|
|
2419
2814
|
ov.setAttribute('aria-label',FINDS?label:HUB?'Switch bundle':'Command palette');
|
|
2420
2815
|
document.getElementById('sw-hint').textContent='↑↓ move · ⏎ open · '+(HUB?(isMac?'⌘⏎':'Ctrl-⏎')+' new tab · ':'')+'Esc close';
|
|
@@ -2625,12 +3020,17 @@ function bridgeReport(v,shown,total){if(bridge)bridge.report(v,shown,total);}
|
|
|
2625
3020
|
/* A row the hub could not load links nowhere, because there is nowhere to
|
|
2626
3021
|
link — but it is still a row: "where did my bundle go?" is the question it
|
|
2627
3022
|
exists to answer. */
|
|
3023
|
+
/* Named by slug, for the reason the palette rows are: the slug is what
|
|
3024
|
+
addresses this bundle, the folder is where it happens to sit. Unlike the
|
|
3025
|
+
hub's own /b/ page this row carries no full path, so the folder stays — as
|
|
3026
|
+
the fact under the name, and only where it is not the name repeated. */
|
|
2628
3027
|
const title=b.mount!==null
|
|
2629
|
-
?'<a class="ws-title" href="../'+encodeURIComponent(b.mount)+'/"
|
|
2630
|
-
:'<span class="ws-title off"
|
|
3028
|
+
?'<a class="ws-title" href="../'+encodeURIComponent(b.mount)+'/">@'+esc(b.slug)+'</a>'
|
|
3029
|
+
:'<span class="ws-title off">@'+esc(b.slug)+'</span>';
|
|
3030
|
+
const where=bundleWhere(b);
|
|
2631
3031
|
const count=b.count===null?'':'<span>'+b.count+' concept'+(b.count===1?'':'s')+'</span>';
|
|
2632
3032
|
const body='<div class="ws-row-main"><div class="ws-t">'+title+pills+'</div>'+
|
|
2633
|
-
'<div class="ws-meta"
|
|
3033
|
+
'<div class="ws-meta">'+(where?'<span class="ws-where">'+esc(where)+'</span>':'')+count+
|
|
2634
3034
|
'<span class="ws-health '+esc(b.health)+'">'+esc(b.word)+'</span></div>'+
|
|
2635
3035
|
(editing===b.slug?edit(b):'')+(confirming===b.slug?confirm(b):'')+'</div>';
|
|
2636
3036
|
return '<div class="ws-row" data-slug="'+esc(b.slug)+'">'+body+(manageable()?menuBtn(b):'')+'</div>';}
|
|
@@ -3004,10 +3404,11 @@ const KEYS=[
|
|
|
3004
3404
|
[ '/', 'search the current view (where it has one)' ],
|
|
3005
3405
|
[ '\\', 'show or hide the inspector — graph' ],
|
|
3006
3406
|
[ '0', 'fit the graph to the screen — graph' ],
|
|
3007
|
-
[ 'f', 'fullscreen' ],
|
|
3008
3407
|
[ /Mac|iP/.test(navigator.platform||'')?'⌘K':'Ctrl-K',
|
|
3009
|
-
SEARCH_ENDPOINT!==null?'search every bundle, switch bundle, or jump to a view'
|
|
3010
|
-
|
|
3408
|
+
SEARCH_ENDPOINT!==null?(HUB_PATH!==null?'search every bundle, switch bundle, or jump to a view'
|
|
3409
|
+
:'search this bundle, or jump to a view')
|
|
3410
|
+
:HUB_PATH!==null?'switch bundle, or jump to a view':'jump to a view' ],
|
|
3411
|
+
[ '⌥ drag', 'move a cluster box — a plain drag pans the canvas' ],
|
|
3011
3412
|
[ '?', 'this list' ],
|
|
3012
3413
|
[ 'Esc', 'close what is open, or clear the graph selection' ]
|
|
3013
3414
|
];
|
|
@@ -3034,7 +3435,7 @@ addEventListener('keydown',e=>{if(!dgv.hidden){if(e.key==='Escape')closeDiagram(
|
|
|
3034
3435
|
else if(e.key==='\\'&&view==='graph'){setSide(gbody.getAttribute('data-side')==='hidden'?'default':'hidden');}
|
|
3035
3436
|
else if(e.key==='0'&&view==='graph'){fitGraph();}
|
|
3036
3437
|
else if(e.key==='Escape'&&view==='graph'){deselect();}
|
|
3037
|
-
|
|
3438
|
+
});
|
|
3038
3439
|
|
|
3039
3440
|
/* ── deep links ── */
|
|
3040
3441
|
const Q=new URLSearchParams(location.search),QL=Q.get('layout'),QS=Q.get('select');
|