Repository navigation
Expand file tree
/
Copy pathstage.js
More file actions
1129 lines (1087 loc) · 69.1 KB
/
Copy pathstage.js
File metadata and controls
1129 lines (1087 loc) · 69.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// The stage: one drawing and one card, shared by every page that names its data —
// the example page's instance and the model page's vocabulary are the same shapes with
// different files behind them, so this is a file both link rather than a copy in each.
//
// Focus and context. The canvas never shows the whole graph: one focused node, its
// ancestors to the left (its path on disk), its children to the right, and its references in
// two dashed bands — targets below, sources above. Everything on the canvas is there because
// of the focus, so no dashed line can land on nothing. d3 moves survivors and fades the rest
// when the focus changes; under prefers-reduced-motion every transition is 0 ms, which is also
// the state the share card renders.
//
// Nothing in this script knows a name from either page. It takes types, entities and edges as
// data the page named and the bootstrap fetched, and derives every label, path and count from
// them; the only strings it carries are the two band eyebrows and the root/folder card's word
// for "pages", which the site's language toggle swaps through t(). The entity card and every
// date are rbCard's, from card.js, which a page loads before this file — or the first click
// throws.
// Where this file came from, read while it runs, since the family's one modal is fetched from
// beside it the first time Expand opens it.
var STAGE_SRC = document.currentScript && document.currentScript.src;
function rbStage(data) {
if (!data.entities) return; // the artifact is empty until the site's build has written it
// Embedded (theme-boot sets the flag from `?embed`): another page's dialog holds this one, the
// chat's graph. The stage then opens expanded, keeps its history to itself rather than in the
// tab's, where the visitor's Back would walk a graph they had closed, and talks to its parent
// in four same-origin messages: it says `rb-graph-ready` and `rb-graph-close`, and takes
// `rb-graph-focus` and `rb-graph-look`.
var EMBED = document.documentElement.hasAttribute("data-embed");
function tell(type, extra){
if (!EMBED || window.parent === window) return;
var m = { type: type }; for (var k in extra || {}) m[k] = extra[k];
window.parent.postMessage(m, location.origin);
}
// Which folder of the model repository the data this page named was generated from. The page
// says so on #srclink, because the page is the thing that knows: the example page reads
// `example/`, the model page `core/`, and the script only pins the commit.
var src = document.getElementById("srclink");
// Which repository the data came from is the data's business, not this file's: the same
// stage draws companygraph.io's example and vocabulary and blust.ch's own model, and they
// are different repositories. The fallback is the one page whose builder does not emit
// `repo` yet; remove it when it does.
var repo = data.repo || "companygraph/meta-model";
src.href = "https://github.com/" + repo + "/tree/" + data.commit + "/" +
(src.getAttribute("data-src") || "example");
document.getElementById("srccommit").textContent = data.commit.slice(0, 7);
// ── the four strings this script owns ─────────────────────────────────────────────────
// The static markup carries its German in data-de and the page's own toggle applies it.
// What the figure draws is built after that pass has run, so these travel with the script
// and re-render when <html lang> changes.
var STR = {
out: { en:"refers to", de:"Verweist auf" },
graphHead: { en:"graph \u00b7 {title}", de:"Graph \u00b7 {title}" },
"in": { en:"referred by", de:"Verwiesen von" },
pages: { en:"pages", de:"Seiten" },
// the history control, which the script builds and so labels itself
trail: { en:"Where you have been on this page", de:"Wo Sie auf dieser Seite waren" },
start: { en:"Back to where the page opened", de:"Zurück zum Anfang" },
back: { en:"Back", de:"Zurück" },
next: { en:"Next", de:"Weiter" },
backTo:{ en:"Back to ", de:"Zurück zu " },
nextTo:{ en:"Forward to ", de:"Weiter zu " },
folder:{ en:"folder", de:"Ordner" },
// the notes on the two controls that open and close the expanded stage: what each does,
// since Expand's own word does not say where it goes and the close button is a bare cross
expand:{ en:"Open full screen", de:"Im Vollbild öffnen" },
shrink:{ en:"Close full screen", de:"Vollbild schliessen" }
};
function lang(){ return document.documentElement.lang === "de" ? "de" : "en"; }
function t(k){ return STR[k][lang()]; }
function stampOf(p){ return (p.node && p.node.entity && p.node.entity.stamp) || null; }
// Everything secondary about a node goes on one line beneath its name: what an experience
// was and when, what level a skill is claimed at, which field an edge came from. They used to
// sit in two places — the period under the name, the level after it — which read as two
// different kinds of fact when they are the same kind: a detail about the node above.
//
// After the name was also the expensive place. The arm is sized by the longest name in the
// column plus whatever follows it, so a level pushed every node in the band further out; a
// line underneath costs no width at all.
function underText(p){
var parts = [stampText(p), attrOf(p)].filter(function(x){ return x; });
return parts.join(" · ");
}
// Kind first, then when: the category is the shorter and the more scannable of the two, so
// a column of these reads down its left edge.
function stampText(p){
var st = stampOf(p); if (!st) return "";
var when = rbCard.fmtPeriod(st, lang());
return st.kind && when ? st.kind + " · " + when : (st.kind || when);
}
// ── model ─────────────────────────────────────────────────────────────────────────────
// Node keys are addresses, because that is where a page sits on disk: "<folder>" is a
// folder, "<folder>/<entity>" a page, "<folder>/<entity>/<owned folder>" a folder a page
// owns. The one invented key is "root", which has no path of its own. An entity's id says
// which entity it is and is opaque — a stable id once the instance carries one — so an
// entity node keeps it beside its key, and every edge, owner and lookup goes by the id. A
// model written before entities carried an address has its path as its id, which is why
// where a page sits falls back to the id.
var byId = {}; data.entities.forEach(function(e){ byId[e.id] = e; });
function where(e){ return e.address != null ? e.address : e.id; }
// The root and the identity entity are one thing: the company. Drawing both would put the
// same name on the canvas twice, and the root would carry a page count where the entity has
// a tagline, contact and prose to show.
var rootEntity = data.rootId ? byId[data.rootId] : null;
// A singular type is one entity in the container and has no folder (core 0.4.0, R6/R13),
// so it hangs off the root directly. Everything else reaches the root through its folder.
var rootTypes = data.types.filter(function(t){ return !t.owner && t.folder; });
var singularTypes = data.types.filter(function(t){ return !t.owner && !t.folder; });
function isSingular(type){ return singularTypes.some(function(t){ return t.type === type; }); }
// A folder node is named for its type, so what kind of thing it is reads off the canvas. A
// singular entity is named for itself and sits among those folders, so its type would be the
// one thing on the level nothing states — this puts it back.
function typeOf(d){
var e = d.node.entity;
if (!e) return "";
// A node reached through its folder needs no label: it is standing under `skills` and is
// obviously a skill. A node in a band was not reached that way. "refers to · 1
// experience-kind · 11 skills · 1 source" counts the types and then draws thirteen names
// with nothing saying which is which, so the one node that is not a skill looks exactly
// like the eleven that are.
if (d.role === "out" || d.role === "in") return e.type;
// A singular type is named for itself and sits among folders, so its type is the one thing
// on that level nothing states.
return isSingular(e.type) ? e.type : "";
}
function ownedTypes(type){ return data.types.filter(function(t){ return t.owner === type; }); }
var cache = {};
function keep(n){ return cache[n.id] || (cache[n.id] = n); }
function nRoot(){ return keep({ kind:"root", id:"root", label:data.root, entity:rootEntity }); }
function nFolder(id, type, ownerId){ return keep({ kind:"folder", id:id, type:type, ownerId:ownerId, label:id.slice(id.lastIndexOf("/") + 1) }); }
function nEntity(e){ return keep({ kind:"entity", id:where(e), label:e.name, entity:e }); }
function folderIdOf(id){ return id.slice(0, id.lastIndexOf("/")); }
function parentOf(n){
if (n.kind === "root") return null;
if (n.kind === "folder") return n.ownerId ? nEntity(byId[n.ownerId]) : nRoot();
var e = n.entity;
if (isSingular(e.type)) return nRoot();
return nFolder(folderIdOf(where(e)), e.type, e.owner);
}
function childrenOf(n){
if (n.kind === "root") return data.entities
.filter(function(e){ return isSingular(e.type) && e !== rootEntity; })
.map(nEntity)
.concat(rootTypes.map(function(t){ return nFolder(t.folder, t.type, null); }));
if (n.kind === "folder") return data.entities
.filter(function(e){ return e.type === n.type && e.owner === n.ownerId; })
.map(nEntity);
return ownedTypes(n.entity.type).map(function(t){ return nFolder(n.id + "/" + t.folder, t.type, n.entity.id); });
}
function ancestorsOf(n){ var out = [], p = parentOf(n); while (p) { out.unshift(p); p = parentOf(p); } return out; }
// The node an id or an address names. An entity's id is looked up directly; anything else
// is an address — a folder's or an entity's — found by walking down from the root through
// the same childrenOf() the canvas uses. An address is a path on disk, so every prefix of
// it is a node, and the walk needs no second index and no name from either page. Returns
// null for what no page here holds, which is what a hand-edited hash looks like.
function nodeById(id){
if (!id) return null;
if (rootEntity && (id === rootEntity.id || id === where(rootEntity))) return nRoot();
if (byId[id]) return nEntity(byId[id]);
var n = nRoot();
for (;;) {
var kids = childrenOf(n), next = null;
for (var i = 0; i < kids.length; i++) {
if (kids[i].id === id) return kids[i];
if (id.indexOf(kids[i].id + "/") === 0) { next = kids[i]; break; }
}
if (!next) return null;
n = next;
}
}
function pathOf(n){ return n.kind === "root" ? [] : n.id.split("/"); }
// Pages held: for the root every entity, for a folder everything filed beneath it — an
// entity and the folder it owns both count as pages of the folder that holds the entity.
function pagesUnder(n){
if (n.kind === "root") return data.entities.length;
return data.entities.filter(function(e){ return where(e).indexOf(n.id + "/") === 0; }).length;
}
function refsOut(n){ return n.kind !== "entity" ? [] : data.edges.filter(function(x){ return x.from === n.entity.id; })
.map(function(x){ return { node:nEntity(byId[x.to]), attrs:x.attrs, label:x.label, edge:x }; }); }
// A proficiency level has no "referred by" band and an experience kind does. That is the
// model, not a gap here, and it has been asked about: a kind is a field in an experience's
// frontmatter, so R4 makes a real edge experience → kind; a level is a cell in a row of the
// profile's Skills table, and R4 makes one edge per row from its FIRST resolving cell — the
// skill. The level is left in that edge's `attrs`, already resolved to an id, which is why
// `attrText` below can dereference it with `byId`.
//
// So a level qualifies a claim, where a kind is a property of a thing, and matching `attrs`
// here would not fix the asymmetry so much as hide it: every row that names a level comes
// from the one profile, so the band would read "referred by · 1 profile" on every level.
// What a reader actually wants — the skills claimed at that level — is the OTHER end of
// those rows, and no skill refers to a level. Left absent deliberately.
function refsIn(n){ return n.kind !== "entity" ? [] : data.edges.filter(function(x){ return x.to === n.entity.id; })
.map(function(x){ return { node:nEntity(byId[x.from]), attrs:x.attrs, label:x.label, edge:x }; }); }
// An attribute value is worth putting on the canvas only if it is short enough to read
// beside a label — a Level is, a paragraph of evidence is not. The card carries the rest.
function attrText(attrs){
var out = [];
Object.keys(attrs || {}).forEach(function(k){
var v = attrs[k];
if (typeof v !== "string") return;
var shown = byId[v] ? byId[v].name : v;
if (shown.length <= 24) out.push(shown);
});
return out.join(" · ");
}
// What a band points at, counted and named: "refers to · 58 skills · 1 source". The model
// supplies both words — the type is the singular and its folder is the plural (R7), which
// is why a count of one reads "1 source" and not "1 sources", and why these are the same
// words the canvas prints on the folders themselves.
//
// Broken down by type rather than totaled, because the honest answer usually is mixed: a
// profile refers to its skills and to the source that masters it, and "59" alone tells the
// reader neither what is down there nor that the last one is a different kind of thing.
// Past three types the breakdown is longer than the thing it labels, so it gives up and
// says how many.
var folderOf = {};
data.types.forEach(function(ty){ folderOf[ty.type] = ty.folder || ty.type; });
function noun(list){
var counts = {};
list.forEach(function(p){
if (p.node && p.node.entity) counts[p.node.entity.type] = (counts[p.node.entity.type] || 0) + 1;
});
var ks = Object.keys(counts).sort();
if (!ks.length) return String(list.length);
if (ks.length > 3) return String(list.length);
return ks.map(function(k){ return counts[k] + " " + (counts[k] === 1 ? k : folderOf[k]); }).join(" · ");
}
// One entity, one node. A focus can reach the same entity by several edges — a profile's
// claim on a skill and each evidence row under it, three fields of a phase naming one seat,
// the process that owns a phase and also lists it — and what the canvas draws is entities,
// so each is placed once. Everything is keyed by id downstream: the positions a line is
// drawn between and the join that enters and exits nodes. A second node for one entity was
// not just drawn twice, it took the first one's position, so the process a phase hangs from
// pulled the spine up to its copy in the band.
//
// The first place an entity is found keeps it: on the path above the focus, among what the
// focus owns, then in a band. Every further edge to it is a line to that one node, and its
// short text joins the node's own, so nothing an edge said is lost and nothing is drawn
// twice. An entity already on the path stays there, and its reference is the dashed line
// from it to the focus. A band counts entities, since it counts its nodes.
function neighbourhood(f){
var nodes = [], links = [], placed = {}, linked = {}, refs = [];
function place(p){ placed[p.node.id] = p; nodes.push(p); return p; }
function link(l){
var k = l.kind + ":" + l.from + "→" + l.to;
if (!linked[k]) { linked[k] = true; links.push(l); }
}
var anc = ancestorsOf(f);
anc.forEach(function(a, i){ place({ node:a, role:"ancestor", i:i }); });
place({ node:f, role:"focus", i:0 });
childrenOf(f).forEach(function(c, i){ place({ node:c, role:"child", i:i }); });
function band(list, role){
var i = 0;
list.forEach(function(r){
var id = r.node.id;
if (id === f.id) return;
var p = placed[id];
if (!p) p = place({ node:r.node, role:role, i:i++, attrs:r.attrs, label:r.label, more:[] });
else if (p.more) p.more.push(r);
refs.push(role === "out" ? { from:f.id, to:id, kind:"ref", attrs:r.attrs }
: { from:id, to:f.id, kind:"ref", attrs:r.attrs });
});
}
band(refsOut(f), "out");
band(refsIn(f), "in");
anc.concat([f]).forEach(function(n, i, all){ if (i) link({ from:all[i-1].id, to:n.id, kind:"own", spine:true }); });
nodes.filter(function(p){ return p.role === "child"; })
.forEach(function(p){ link({ from:f.id, to:p.node.id, kind:"own" }); });
refs.forEach(link);
return { nodes:nodes, links:links };
}
// ── geometry ──────────────────────────────────────────────────────────────────────────
// Three relations, three shapes, so the eye separates them before it reads a word:
// the path on disk climbs as a ladder on the left, the way a file tree is drawn;
// what the focus owns hangs as a column on the right;
// what refers, and what is referred to, sit in two dashed bands outside both.
var STEP = 54, ROW = 54, ROW_STAMP = 66, BAND = 96, EYE = 28;
var R_FOCUS = 16, R_NODE = 12; // half-widths of the marks
var GAP = 12; // mark to its label
var CH_MONO = 6.9, CH_TEXT = 6.9, CH_ROOT = 8.6; // width per character, for the fit only
// The camera's floor, shared with the zoom's scaleExtent. It is a legibility limit, not a
// fitting one: a profile that claims 59 skills has a reference band taller than any frame,
// and scaling until it fits produced labels too small to read AND a neighbourhood still
// running off the edge — the worst of both. Below this the drawing stops being worth
// looking at, so the camera stops here and the reader moves instead.
var K_MIN = 0.8; // the camera's floor, one with the zoom's
function markW(p){ return p.role === "focus" ? R_FOCUS : R_NODE; }
// Half the mark's height, which is not half its width for a folder: a folder's box is drawn
// 4px taller than a page's square so the two read as different shapes, and the spine has to
// stop at the edge that is actually there. It used a flat R_NODE for both ends, so a line
// into the focus — 4px wider and 2px taller again — ran six pixels inside its box, and a
// line out of any folder started two pixels inside that one.
function markH(p){ return markW(p) + (p.node.kind === "entity" ? 0 : 2); }
function nameW(p){
var per = p.node.kind === "root" ? CH_ROOT : p.node.kind === "folder" ? CH_MONO : CH_TEXT;
return p.node.label.length * per;
}
// What is written in mono beside a referenced node. An edge that carries a label draws the
// label and nothing else — the field the reference is written in, which is what the model
// page labels every line with, and which already names what the edge's attributes restate;
// its attributes stay in the card, where there is room to read them. An edge with no label
// draws its short attributes, as an assessment's Level is drawn today, so a block whose
// edges carry none draws exactly what it drew before.
// A node that several edges reach carries each one's text once, in the order the edges came.
function attrOf(p){
if (p.role !== "out" && p.role !== "in") return "";
var texts = [p].concat(p.more || []).map(function(r){
return typeof r.label === "string" && r.label ? r.label : attrText(r.attrs);
});
return texts.filter(function(s, i){ return s && texts.indexOf(s) === i; }).join(" · ");
}
// The widest of the three stacked lines, not their sum. The type can be longer than the name
// it labels — `experience-kind` over `Role` — so leaving it out of the fit clipped the band
// it was meant to explain.
function labelW(p){
return Math.max(nameW(p), underText(p).length * CH_MONO, typeOf(p).length * CH_MONO);
}
function layout(neigh, w){
// The two arms narrow with the canvas. A phone's canvas is a third of a desktop's, and
// holding the desktop offsets there would either run the path off the left edge or shrink
// the whole drawing past reading size — both of which the fixed stage exists to prevent.
var LEFT = Math.min(230, Math.max(118, w * 0.36));
var RIGHT = Math.min(210, Math.max(106, w * 0.33));
var anc = neigh.nodes.filter(function(p){ return p.role === "ancestor"; });
var kids = neigh.nodes.filter(function(p){ return p.role === "child"; });
var out = neigh.nodes.filter(function(p){ return p.role === "out"; });
var inn = neigh.nodes.filter(function(p){ return p.role === "in"; });
var n = anc.length;
// The path climbs the left edge and turns in at the bottom, the way a file tree is
// drawn. Every ancestor sits above the focus's row, so the segment that carries the
// eye back into the focus runs under the labels rather than through them.
anc.forEach(function(p){ p.x = -LEFT; p.y = -(n - p.i) * STEP; });
neigh.nodes.filter(function(p){ return p.role === "focus"; }).forEach(function(p){ p.x = 0; p.y = 0; });
var top = n ? -n * STEP : 0;
// A stamped node carries a second line under its name, so its row has to open to hold it
// — but only that group's. Fifty-eight skills carry no stamp and stay at the tight row
// they have always had; opening every row for a folder that never draws one would make
// the commonest view taller to serve the rarest.
function rowOf(list){
for (var i = 0; i < list.length; i++) if (underText(list[i])) return ROW_STAMP;
return ROW;
}
var kidRow = rowOf(kids), outRow = rowOf(out), inRow = rowOf(inn);
kids.forEach(function(p){ p.x = RIGHT; p.y = (p.i - (kids.length - 1) / 2) * kidRow; });
var below = kids.length ? kids[kids.length - 1].y : 0;
var outTop = below + BAND;
out.forEach(function(p){ p.x = RIGHT; p.y = outTop + p.i * outRow; });
var inTop = top - BAND - (inn.length - 1) * inRow;
inn.forEach(function(p){ p.x = -LEFT; p.y = inTop + p.i * inRow; });
var bands = [];
// The eyebrow carries the count and, when it can, the word for what is being pointed at:
// "refers to · 59 skills". A band can be taller than the canvas — a profile claiming 59
// skills has one — and a bare number leaves the reader unable to tell whether two more
// sit below the edge or fifty, while a number with a noun says what the drag is for.
//
// The noun is the type's folder, which R7 makes the plural of the type, so it is the same
// word the canvas already prints on the folder itself. A band whose targets are of more
// than one type gets the bare count: naming them all would be a list, and naming one
// would be wrong.
// The eyebrow clears the first node by EYE. A node in a band now names its type on a line
// above its own name, so that clearance has to grow by a line or the eyebrow sits on it —
// two mono lines fourteen pixels apart, which reads as one crowded block rather than a
// heading over a list.
function eyeOf(list){ return list.length && typeOf(list[0]) ? EYE + 14 : EYE; }
if (out.length) bands.push({ key:"out", x:RIGHT - R_NODE, y:outTop - eyeOf(out), anchor:"start", text:t("out") + " · " + noun(out) });
if (inn.length) bands.push({ key:"in", x:-LEFT - R_NODE - GAP, y:inTop - eyeOf(inn), anchor:"end", text:t("in") + " · " + noun(inn) });
neigh.bands = bands;
return neigh;
}
// The camera. Everything above is laid out around the focus at (0,0) and knows nothing
// about the canvas; this is what makes it fit. A neighbourhood four folders deep with a
// long page name is wider than the stage, so the view scales down to hold it rather than
// letting the far end of the path run off the edge — the one thing the fixed stage must
// never do. Shallow neighbourhoods sit at 1:1 and never grow to fill the frame.
function fit(neigh, w, h){
var x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
neigh.nodes.forEach(function(p){
var m = markW(p), w = GAP + labelW(p);
x0 = Math.min(x0, p.x - m - (p.role === "in" ? w : 0));
x1 = Math.max(x1, p.x + m + (p.role === "in" ? 0 : w));
y0 = Math.min(y0, p.y - m - (p.role === "focus" ? 34 : 0)); y1 = Math.max(y1, p.y + m);
});
neigh.bands.forEach(function(b){
y0 = Math.min(y0, b.y - 12);
// Both eyebrows, both directions. Only the left one was measured, which was harmless
// while the right one read "refers to" and started running off the canvas as soon as
// it read "refers to · 58 skills · 1 source".
x0 = Math.min(x0, b.x - (b.anchor === "end" ? b.text.length * CH_MONO : 0));
x1 = Math.max(x1, b.x + (b.anchor === "start" ? b.text.length * CH_MONO : 0));
});
var k = Math.max(K_MIN, Math.min(1, (w * 0.92) / (x1 - x0), (h * 0.9) / (y1 - y0)));
// Center what fits; anchor on the focus what does not. Centering the whole bounding box is
// right until one band is taller than any frame — a profile claiming 59 skills has one —
// and then the box's middle is somewhere inside that band and the focused node itself is
// off the canvas. The reader is left looking at a list with nothing to say what it hangs
// from. Per axis, because the overflow is usually vertical and the horizontal path still
// deserves centering. The layout puts the focus at (0,0), which is what makes this a
// translate to the middle of the frame and nothing more.
var overflowX = k * (x1 - x0) > w, overflowY = k * (y1 - y0) > h;
// Horizontally, hold the left edge: the path climbs in from the left and the focus sits
// after it, so reading order is what should survive the clip. Centering instead pushed
// the right-hand eyebrow past the edge — the canvas is only as wide as the card leaves it.
//
// But never past the middle. That rule was written believing the focus could not be the
// thing that falls off, and on a wide canvas it cannot. The focus is at x 0, so it lands
// on screen at tx exactly, and what tx measures is the left arm: the ancestors, the band
// of referrers and that band's own eyebrow, which is a sentence rather than a name. On a
// phone that arm alone is wider than the canvas, so holding its left edge put the focused
// node off the right — a deep link answered with an empty corner, which is the one thing
// a camera owes the link. Clamped, the focus is at worst centered, its own name and its
// children stay on, and the far end of the left arm becomes what the reader drags to
// rather than what the reader is left holding.
// Vertically, hold the focus: a band overflows in both directions at once and there is no
// edge worth preferring, only the node the band hangs from.
var tx = overflowX ? Math.min(w * 0.04 - k * x0, w / 2) : w / 2 - k * (x0 + x1) / 2;
var ty = overflowY ? h / 2 : h / 2 - k * (y0 + y1) / 2;
return d3.zoomIdentity.translate(tx, ty).scale(k);
}
// ── the stage ─────────────────────────────────────────────────────────────────────────
var svg = d3.select("#fig");
var scene = svg.append("g");
var gLink = scene.append("g"), gBand = scene.append("g"), gNode = scene.append("g");
var pathLine = document.getElementById("path");
var cbody = document.getElementById("cbody"), cfoot = document.getElementById("cfootlink");
var modal = document.getElementById("stagemodal"), expandBtn = document.getElementById("expand");
// Expand moves the stage itself into the dialog rather than rendering a copy into it, so
// every handler bound to #fig, #card and its links keeps working unchanged.
//
// A marker holds the place. It used to reinsert both elements before #figcap, on the
// reasoning that the caption never moves — true, but it assumed nothing else sits between
// the stage and the caption, and the moment a page put a line there (how to drag the
// drawing) the stage came back on the wrong side of it. A comment node left where the
// stage was cannot be wrong about what the page contains, because it is not a claim about
// the page at all.
var stageHead = document.getElementById("stagehead"), stageEl = document.getElementById("stage");
var stageHome = stageHead.parentNode, stageMark = document.createComment("stage");
// ── the history ───────────────────────────────────────────────────────────────────────
// Every focus but the root is a place with an address, so the browser's Back and Forward
// already move the focus; what the page lacked was a control that says so. This is the
// deck's transport, cut to the head row: first, back, a readout, next. It acts on the
// browser's history and on nothing else — back() and forward() and go() — and the
// hashchange listener below turns each into a focus, so this control, the browser's own
// buttons and the keyboard can never disagree about where the visitor is.
//
// What the browser does not tell a page is whether there is anywhere to go, so the stage
// keeps a trail of its own: the addresses it has focused on this visit, and a position in
// them. A click pushes; a hashchange that lands on the neighbor behind or ahead moves the
// position; the first entry is the page as it opened, and Back from it would leave the
// page, which is the browser's Back to give and not this one's. The root has no address
// and is written with replaceState, so a click on it replaces the entry it was clicked
// from, in the trail as in the browser.
//
// Built here rather than in the page's markup so that a page takes it with the script and
// nothing else — the same reason the band eyebrows are the script's. aria-disabled, not
// disabled: a real disabled state drops the focus on the floor the moment Next runs out of
// road, which is exactly when the keyboard is on it.
var trail = [], pos = -1, pending = null, expect = -1;
function ctl(cls, svgBody){
var b = document.createElement("button"); b.type = "button"; b.className = cls;
b.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true">' + svgBody + '</svg>';
return b;
}
// The slab holds the instrument; the two names beside it are the page's own text, in
// fixed slots so the instrument never moves as the names change length. Each is what the
// button nearest it would do, and the buttons already say so to a screen reader, so the
// names are hidden from it rather than read twice.
var hist = document.createElement("div"); hist.className = "history"; hist.setAttribute("role", "group");
var slab = document.createElement("div"); slab.className = "slab";
var hWas = document.createElement("span"); hWas.className = "step was"; hWas.setAttribute("aria-hidden", "true");
var hWill = document.createElement("span"); hWill.className = "step will"; hWill.setAttribute("aria-hidden", "true");
var hFirst = ctl("first", '<rect x="4.4" y="5" width="2.3" height="14" rx="1"/><path d="M13.2 5.4v13.2L7.6 12z"/><path d="M21 5.4v13.2L13.6 12z"/>');
var hBack = ctl("back", '<path d="M16.6 4.6v14.8L6 12z"/>');
var hNext = ctl("next", '<path d="M7.4 4.6v14.8L18 12z"/>');
var hLcd = document.createElement("div"); hLcd.className = "lcd"; hLcd.setAttribute("aria-hidden", "true");
hLcd.innerHTML = '<b class="cur">01</b><span class="sep">/</span><span class="tot">01</span>';
slab.appendChild(hFirst); slab.appendChild(hBack); slab.appendChild(hLcd); slab.appendChild(hNext);
hist.appendChild(hWas); hist.appendChild(slab); hist.appendChild(hWill);
stageHead.insertBefore(hist, expandBtn);
function able(b, ok){ b.setAttribute("aria-disabled", ok ? "false" : "true"); }
function trailNode(i){ return nodeById(trail[i]) || nRoot(); }
function trailName(i){ return trailNode(i).label; }
// The kind of thing a step is, the way the figure's eyebrow says it: an entity's type, the
// word for a folder, and for the root the type of the entity it carries, when it does.
function trailType(i){
var n = trailNode(i);
if (n.kind === "entity") return n.entity.type;
if (n.kind === "folder") return t("folder");
return n.entity ? n.entity.type : "";
}
function tipFor(el, i){
if (i < 0) { rbCard.undescribe(el); return; }
var n = trailNode(i);
rbCard.describe(el, trailType(i), trailName(i), n.entity && n.entity.tagline ? n.entity.tagline : "");
}
function setStep(el, i){
el.innerHTML = "";
if (i < 0) { tipFor(el, -1); return; }
tipFor(el, i);
var k = document.createElement("i"); k.className = "k"; k.textContent = trailType(i);
var b = document.createElement("b"); b.textContent = trailName(i);
el.appendChild(k); el.appendChild(b);
}
function renderHist(){
var canBack = pos > 0, canNext = pos < trail.length - 1;
able(hFirst, canBack); able(hBack, canBack); able(hNext, canNext);
hist.setAttribute("aria-label", t("trail"));
hFirst.setAttribute("aria-label", t("start"));
hBack.setAttribute("aria-label", canBack ? t("backTo") + trailName(pos - 1) : t("back"));
hNext.setAttribute("aria-label", canNext ? t("nextTo") + trailName(pos + 1) : t("next"));
// The hover is the place's card in miniature, through card.js's tooltip: its type, its
// name, its one line. A side with nowhere to go says nothing on hover; the label still
// names the button.
tipFor(hFirst, canBack ? 0 : -1); tipFor(hBack, canBack ? pos - 1 : -1); tipFor(hNext, canNext ? pos + 1 : -1);
setStep(hWas, canBack ? pos - 1 : -1); setStep(hWill, canNext ? pos + 1 : -1);
hLcd.firstChild.textContent = String(pos + 1).padStart(2, "0");
hLcd.lastChild.textContent = String(trail.length).padStart(2, "0");
}
function offRoad(ev){ return ev.currentTarget.getAttribute("aria-disabled") === "true"; }
// One move along the history: the browser's own on a page, the trail itself when embedded.
function step(delta){
if (!EMBED) { if (delta === -1) history.back(); else if (delta === 1) history.forward(); else history.go(delta); return; }
var to = pos + delta;
if (to < 0 || to >= trail.length) return;
pos = to;
focus(nodeById(trail[pos]) || nRoot(), true);
renderHist();
}
hFirst.addEventListener("click", function(ev){ if (!offRoad(ev) && pos > 0) { if (!EMBED) expect = 0; step(-pos); } });
hBack.addEventListener("click", function(ev){ if (!offRoad(ev)) step(-1); });
hNext.addEventListener("click", function(ev){ if (!offRoad(ev)) step(1); });
// The deck's keys: Left is back, Right is next, Home is first. The deck binds them to the
// document and nothing else lives there; a prose page has a header, a language control,
// links and the drag handle, so here they yield to anything that already uses the key —
// a handler that prevented the default, a field being typed in — and to any modifier,
// because Alt-Left is the browser's own Back and Cmd-Left goes to the start of a line.
document.addEventListener("keydown", function(ev){
if (ev.defaultPrevented || ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey) return;
var el = ev.target;
if (el && el.closest && el.closest("input, textarea, select, [contenteditable=''], [contenteditable='true']")) return;
if (ev.key === "ArrowLeft") { if (pos > 0) { step(-1); ev.preventDefault(); } }
else if (ev.key === "ArrowRight") { if (pos < trail.length - 1) { step(1); ev.preventDefault(); } }
else if (ev.key === "Home") { if (pos > 0) { if (!EMBED) expect = 0; step(-pos); ev.preventDefault(); } }
});
// The trail learns from the address, never from a click directly: whichever way the
// address moved — this control, the browser's buttons, a typed hash — the same reading
// applies. A click's own hashchange is announced through `pending` so it is not read as
// a step back onto an entry that happens to carry the same address. A jump of more than
// one step — first, or the browser's own long-press list — lands on no neighbor: our own
// jump says where it is going through `expect`, and any other is read as the nearest
// entry that carries the address, because a place the trail holds is never a new place.
// Only an address the trail has never seen is pushed.
function trailMove(key){
if (pending !== null && pending === key) { pending = null; return; }
if (expect >= 0) { var e = expect; expect = -1; if (trail[e] === key) { pos = e; return; } }
if (pos > 0 && trail[pos - 1] === key) { pos--; return; }
if (pos < trail.length - 1 && trail[pos + 1] === key) { pos++; return; }
if (trail[pos] === key) return;
for (var d = 2; d < trail.length; d++) {
if (pos - d >= 0 && trail[pos - d] === key) { pos -= d; return; }
if (pos + d < trail.length && trail[pos + d] === key) { pos += d; return; }
}
trail = trail.slice(0, pos + 1); trail.push(key); pos = trail.length - 1;
}
var reduce = window.matchMedia ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
var first = true;
function dur(){ return (first || (reduce && reduce.matches)) ? 0 : 400; }
// A plain wheel over the canvas must keep scrolling the page — only ctrl/⌘+wheel zooms — and
// dblclick is left to the browser rather than jumping the camera to 2× on whatever it hit.
// The closed hand appears when the drawing actually moves and not before: on the pointer
// move that pans, never on the press. A press that does not move is a click on a node, and
// showing a grab for it would promise the wrong thing. Wheel and programmatic transitions
// carry no source event, so neither touches the cursor.
var zoom = d3.zoom().scaleExtent([K_MIN, 2])
.filter(function(ev){ return ev.type === "wheel" ? (ev.ctrlKey || ev.metaKey) : ev.type !== "dblclick"; })
.on("zoom", function(ev){
scene.attr("transform", ev.transform);
var src = ev.sourceEvent;
if (src && /move/.test(src.type)) svg.classed("panning", true);
})
.on("end", function(){ svg.classed("panning", false); });
svg.call(zoom);
var home = d3.zoomIdentity;
var focused = null;
function size(){
var r = document.getElementById("fig").getBoundingClientRect();
return { w: r.width || 620, h: r.height || 520 };
}
// Every label is painted with a halo of the canvas's own color behind it, so a line that
// has to pass under a name does not cut through it. Inline styles rather than attributes,
// because a presentation attribute cannot carry a var() and the color is a token.
function halo(sel){
return sel.style("paint-order", "stroke").style("stroke", "var(--raise)")
.style("stroke-width", "3.5px").style("stroke-linejoin", "round");
}
var linkH = d3.linkHorizontal().x(function(d){ return d[0]; }).y(function(d){ return d[1]; });
// Two line shapes, because two of the three relations are not the same kind of thing.
// Owning downward and referring both fan out of the focus, so they are curves that leave
// and arrive horizontally, trimmed to the marks so nothing is drawn inside a box. The
// path climbs as a right angle instead: a ladder is what a folder chain looks like.
function shape(l, a, b){
if (l.spine) {
var x = a.x;
if (a.x === b.x) return "M" + x + " " + (a.y + markH(a)) + "V" + (b.y - markH(b));
return "M" + x + " " + (a.y + markH(a)) + "V" + (b.y - 10) +
"Q" + x + " " + b.y + " " + (x + 10) + " " + b.y +
"H" + (b.x - markW(b));
}
var s = [a.x + markW(a), a.y], e = [b.x - markW(b), b.y];
return linkH({ source:s, target:e });
}
function render(){
var dim = size(), D = dur();
var neigh = layout(neighbourhood(focused), dim.w);
var pos = {}; neigh.nodes.forEach(function(p){ pos[p.node.id] = p; });
var links = neigh.links.filter(function(l){ return pos[l.from] && pos[l.to]; });
// path:not(.gone) — an exiting path is marked .gone the moment it exits so a link that
// re-enters before its fade finishes is never re-bound to the still-fading exit copy.
var lsel = gLink.selectAll("path:not(.gone)").data(links, function(d){ return d.from + "→" + d.to; });
lsel.exit().classed("gone", true).style("pointer-events", "none")
.transition().duration(D).style("opacity", 0).remove();
lsel.enter().append("path")
.attr("data-from", function(d){ return d.from; })
.attr("data-to", function(d){ return d.to; })
.attr("d", function(d){ return shape(d, pos[d.from], pos[d.to]); })
.style("opacity", 0).transition().duration(D).style("opacity", 1);
// An update always drives opacity/pointer-events back to their settled state, so an enter
// fade interrupted by a second click within the transition window still finishes instead
// of leaving the survivor translucent and unclickable.
// `spine` reaches the DOM now — it was set on the ancestor chain when the neighbourhood
// was built, used for geometry, and thrown away before it was drawn, so the path from the
// root to the focus looked like any other ownership line.
//
// Classed on the update, not on the enter. A link keyed from→to survives a click, and
// whether it is part of the spine depends on where the focus now is: the link into a
// folder is an ordinary line until you descend through it, at which point it becomes the
// path. Setting the class only where a path is created leaves every survivor wearing the
// answer to a question asked one focus ago.
lsel.merge(gLink.selectAll("path:not(.gone)"))
.attr("class", function(d){ return d.kind === "own" ? (d.spine ? "own spine" : "own") : "ref"; });
lsel.style("pointer-events", null).transition().duration(D)
.style("opacity", 1).attr("d", function(d){ return shape(d, pos[d.from], pos[d.to]); });
var bsel = gBand.selectAll("text:not(.gone)").data(neigh.bands, function(d){ return d.key; });
bsel.exit().classed("gone", true).transition().duration(D).style("opacity", 0).remove();
halo(bsel.enter().append("text")).attr("class", "eyebrow")
.attr("x", function(d){ return d.x; }).attr("y", function(d){ return d.y; })
.attr("text-anchor", function(d){ return d.anchor; })
.text(function(d){ return d.text; })
.style("opacity", 0).transition().duration(D).style("opacity", 1);
bsel.text(function(d){ return d.text; }).attr("text-anchor", function(d){ return d.anchor; })
.transition().duration(D).style("opacity", 1)
.attr("x", function(d){ return d.x; }).attr("y", function(d){ return d.y; });
var nsel = gNode.selectAll("g.n").data(neigh.nodes, function(d){ return d.node.id; });
// An exiting node keeps its marks so it can fade, but loses the class the page and the
// suite select on: a node that is on its way out is no longer on the canvas.
nsel.exit().classed("n", false).attr("tabindex", null).style("pointer-events", "none")
.transition().duration(D).style("opacity", 0).remove();
var enter = nsel.enter().append("g")
.attr("class", "n").attr("role", "button").attr("tabindex", 0)
.attr("data-id", function(d){ return d.node.id; })
.attr("data-kind", function(d){ return d.node.kind; })
.attr("transform", function(d){ return "translate(" + d.x + " " + d.y + ")"; })
.style("opacity", 0)
.on("click", function(ev, d){ focus(d.node); })
.on("keydown", function(ev, d){ if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); focus(d.node); } });
enter.append("rect");
halo(enter.append("text")).attr("class", "label");
halo(enter.append("text")).attr("class", "typelab");
halo(enter.append("text")).attr("class", "stamp");
enter.transition().duration(D).style("opacity", 1);
var all = enter.merge(nsel);
all.classed("focus", function(d){ return d.role === "focus"; })
.classed("ancestor", function(d){ return d.role === "ancestor"; })
.attr("aria-current", function(d){ return d.role === "focus" ? "true" : null; });
all.select("rect")
.attr("class", function(d){ return d.node.kind === "entity" ? "sq" : "box"; })
.attr("x", function(d){ return -markW(d); })
.attr("y", function(d){ return -markW(d) - (d.node.kind === "entity" ? 0 : 2); })
.attr("width", function(d){ return markW(d) * 2; })
.attr("height", function(d){ return markW(d) * 2 + (d.node.kind === "entity" ? 0 : 4); })
.attr("rx", function(d){ return d.node.kind === "entity" ? 0 : 4; });
// The focus is the one node whose name also fills the card, so it carries its label
// above the mark: nothing then sits between it and the column it owns.
// Every run of text a node carries hangs off one baseline, so the block moves as a block.
//
// The focus writes its name *above* its mark rather than beside it, and a line beneath the
// name therefore lands on the mark — which is exactly what it did. The whole block lifts by
// a line's height when there is a line to make room for, and sits where it always sat when
// there is not.
function textX(d){ return d.role === "focus" ? -markW(d) : d.role === "in" ? -markW(d) - GAP : markW(d) + GAP; }
function anchorOf(d){ return d.role === "in" ? "end" : null; }
function baseY(d){
if (d.role !== "focus") return 0;
return -markW(d) - 16 - (underText(d) ? 15 : 0);
}
all.select("text.label")
.attr("class", function(d){ return "label " + (d.node.kind === "root" ? "root" : d.node.kind === "folder" ? "folder" : ""); })
.attr("x", textX).attr("y", baseY).attr("text-anchor", anchorOf)
.text(function(d){ return d.node.label; });
// Rides above the label, at the label's own x and anchor, so it reads as one block.
all.select("text.typelab")
.attr("x", textX).attr("y", function(d){ return baseY(d) - 14; }).attr("text-anchor", anchorOf)
.text(typeOf);
// And one line under the name for everything secondary — the period, the level, the field
// an edge came from. It costs no width, which is what a canvas has least of.
all.select("text.stamp")
.attr("x", textX).attr("y", function(d){ return baseY(d) + 15; }).attr("text-anchor", anchorOf)
.text(underText);
nsel.transition().duration(D).style("opacity", 1)
.attr("transform", function(d){ return "translate(" + d.x + " " + d.y + ")"; });
home = fit(neigh, dim.w, dim.h);
if (D) svg.transition().duration(D).call(zoom.transform, home);
else svg.call(zoom.transform, home);
}
// ── the card ──────────────────────────────────────────────────────────────────────────
function h(tag, text, cls){ var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function clear(el){ while (el.firstChild) el.removeChild(el.firstChild); }
function goLink(id){
var a = h("a", byId[id].name, "go");
a.href = "#" + where(byId[id]);
a.addEventListener("click", function(ev){ ev.preventDefault(); focus(nEntity(byId[id])); });
return a;
}
// Renders the focused node into the card's (body, foot) pair. Expand no longer copies this
// into a second element — it moves the card itself into the dialog — so there is exactly
// one target, and a .go link clicked inside it updates the same #cbody/#cfoot whether the
// dialog is open or not.
function renderInto(n, bodyEl, footEl){
clear(bodyEl); clear(footEl);
// The root carries an entity where the instance names its company, so it renders as one:
// a tagline, the fields and the prose, rather than a name over a page count.
if (n.kind !== "entity" && !n.entity) {
// Not empty, and the same shape as an entity's card so the panel never jumps: the
// path in mono where the entity puts its type and path, then one line of what is
// focused and how many pages are filed under it. Both come out of the fetched data.
if (n.kind === "folder") {
bodyEl.appendChild(h("div", n.id, "eyebrow"));
bodyEl.appendChild(h("p", pagesUnder(n) + " " + t("pages"), "empty"));
} else {
// The root's label is a name, not a path, so it is set in prose and carries the card.
bodyEl.appendChild(h("h3", n.label));
bodyEl.appendChild(h("p", pagesUnder(n) + " " + t("pages"), "empty"));
}
return;
}
// An entity's card is the shared one; what the stage adds is the way back into the
// drawing (a resolved reference focuses its node) and, on the root, the page count.
rbCard.render(n.entity, bodyEl, footEl, {
data: data, lang: lang(), link: goLink,
note: n.kind === "root" ? pagesUnder(n) + " " + t("pages") : null
});
}
// The card always renders in place — there is nothing else to keep in sync, since the
// dialog holds the same #card, not a copy of it.
function showCard(n){ renderInto(n, cbody, cfoot); }
// Re-fit after the dialog has actually laid out: showModal() changes the box #fig sits in,
// but getBoundingClientRect() inside render()/fit() won't see the new size until the
// browser has done that layout pass, hence two rAFs rather than calling render() inline.
function refit(){
requestAnimationFrame(function(){
requestAnimationFrame(function(){ if (focused) render(); });
});
}
// Expand opens the family's one modal, fetched from beside this file on the first open and
// used once its stylesheet has arrived, so the stage is fitted to the modal it will be seen
// in. The modal moves the path and the stage in and gives them back where they stood, titles
// itself by the focus, and keeps the page behind still. An embedded page has no modal of its
// own to open: it draws its stage in #stagemodal, filling the frame the chat's modal holds.
var modalLoad = null, modalHandle = null;
function loadModal(){
if (window.rbModal) return window.rbModal.ready.then(function(){ return window.rbModal; });
if (!modalLoad) modalLoad = new Promise(function(resolve, reject){
var sc = document.createElement("script"); sc.src = new URL("modal.js", STAGE_SRC || location.href).href;
sc.onload = function(){ if (window.rbModal) window.rbModal.ready.then(function(){ resolve(window.rbModal); }); else reject(new Error("modal.js set no rbModal")); };
// A failed fetch is not remembered: the next Expand tries again.
sc.onerror = function(){ modalLoad = null; reject(new Error("modal.js did not load")); };
document.head.appendChild(sc);
});
return modalLoad;
}
function graphTitle(n){ return t("graphHead").replace("{title}", n ? n.label : ""); }
// Where the modal cannot be fetched, the stage still expands, into the page's own dialog.
function expand(){
if (EMBED) return expandEmbedded();
return loadModal().then(function(M){
if (modalHandle) return;
modalHandle = M.open({ key: "stage", kind: "graph", title: graphTitle(focused), body: [stageHead, stageEl], opener: expandBtn,
onClose: function(){ modalHandle = null; setCard(storedCard(), false); refit(); } });
// The stage has changed boxes, so it changes memories with it.
setCard(storedCard(), false);
refit();
}, function(){ if (!modalHandle && !modal.open) expandEmbedded(); });
}
function expandEmbedded(){
// Drop the marker where the stage stands before taking it away, so close has somewhere
// exact to put it back — in front of whatever followed it, not in front of the caption.
stageHome.insertBefore(stageMark, stageHead);
modal.append(stageHead, stageEl);
modal.showModal();
setCard(storedCard(), false);
refit();
}
expandBtn.addEventListener("click", expand);
var closeBtn = document.getElementById("modalclose");
closeBtn.addEventListener("click", function(){ if (EMBED) { tell("rb-graph-close"); return; } modal.close(); });
// Embedded, Escape asks the parent to close its dialog, and this one stays open for the next.
modal.addEventListener("cancel", function(ev){ if (EMBED) { ev.preventDefault(); tell("rb-graph-close"); } });
// Both controls carry the family's note, the box the transport already shows, so every
// control on the stage answers a pointer the same way. The cross names its key as well,
// because Escape closes the dialog too and nothing else on the page says so.
function noteControls(){
rbCard.describe(expandBtn, "", t("expand"), "");
rbCard.describe(closeBtn, "", t("shrink"), "Esc");
}
noteControls();
new MutationObserver(noteControls).observe(document.documentElement, { attributes:true, attributeFilter:["lang"] });
// Escape is native to <dialog> and needs no handler here. A click on the backdrop lands
// with the dialog itself as the event target — nothing else is there to hit — which is
// what tells it apart from a click on the content the dialog contains.
modal.addEventListener("click", function(ev){ if (ev.target === modal && !EMBED) modal.close(); });
// One handler for every way the dialog closes — ×, Escape, backdrop click — because all
// three end in the native "close" event. Both go back in front of the marker, in order,
// which puts them exactly where they were whatever else the page has around them.
modal.addEventListener("close", function(){
stageHome.insertBefore(stageHead, stageMark);
stageHome.insertBefore(stageEl, stageMark);
if (stageMark.parentNode) stageMark.parentNode.removeChild(stageMark);
setCard(storedCard(), false);
refit();
expandBtn.focus();
});
// ── focus ─────────────────────────────────────────────────────────────────────────────
function focus(n, fromAddress){
var was = focused;
focused = n;
var segs = pathOf(n);
pathLine.innerHTML = segs.map(function(s, i){
var esc = s.replace(/&/g, "&").replace(/</g, "<");
return i === segs.length - 1 ? "<b>" + esc + "</b>" : esc;
}).join(" / ");
// Every focus but the root is a place, so every focus but the root has a URL. It used to
// be entities only, which made a folder unlinkable and unshareable and left Back landing
// on the root from halfway down a path — and the share card, which asks for a state by
// URL and nothing else, had no way to ask for an opened folder at all.
var hash = n.kind === "root" ? "" : "#" + n.id;
var key = n.kind === "root" ? "" : n.id;
if (!fromAddress && pos >= 0 && (!was || was.id !== n.id)) {
if (hash) { trail = trail.slice(0, pos + 1); trail.push(key); pos = trail.length - 1; pending = key; }
else trail[pos] = key;
}
if (hash) { if (EMBED) { pending = null; history.replaceState(null, "", location.pathname + location.search + hash); } else location.hash = hash; }
else if (location.hash) { try { history.replaceState(null, "", location.pathname + location.search); } catch (err) { location.hash = ""; } }
render();
showCard(n);
renderHist();
// The modal is titled after the place the visitor stands, wherever they walked: this page's
// own modal, or the parent's when embedded.
if (modalHandle) modalHandle.title(graphTitle(n));
tell("rb-graph-at", { title: n.label });
}
document.getElementById("recenter").addEventListener("click", function(){
var D = (reduce && reduce.matches) ? 0 : 400;
if (D) svg.transition().duration(D).call(zoom.transform, home); else svg.call(zoom.transform, home);
});
// The site's language toggle rewrites every [data-de] node and sets <html lang>; the two
// eyebrows and the folder card are built here, after that pass, so they follow the flag.
new MutationObserver(function(){ if (focused) { render(); showCard(focused); renderHist(); if (modalHandle) modalHandle.title(graphTitle(focused)); } })
.observe(document.documentElement, { attributes:true, attributeFilter:["lang"] });
// ── the divider ───────────────────────────────────────────────────────────────────────
// The details pane is fixed at 360px, which is right for a folder's card and wrong for a
// profile claiming fifty-eight skills. A drag handle is what every two-pane tool a visitor
// already uses puts between them, so it is what this uses: drag, double-click to reset,
// arrow keys when focused.
//
// The width is remembered, and clamped on the way back in. A number dragged wide on a large
// monitor would otherwise swallow the canvas on a laptop, and the same clamp handles a
// window resized after the page loaded. The key is a constant here rather than the page's:
// this file is byte-identical on every site that draws a stage and knows none of their
// names, and localStorage is per-origin, so one name cannot collide with another site's.
// Two memories, because there are two boxes. The page gives the stage a ~980px column and
// the dialog gives it nearly the whole window, so a width that is right in one is wrong in
// the other: one number would be clamped to the page's maximum every time the dialog closed,
// and the reader's choice in the wider box would be lost on the way back. The default
// differs for the same reason.
var CARD = { page: { key: "stage-card" }, modal: { key: "stage-card-modal" } };
var CARD_MIN = 280, CANVAS_MIN = 320;
// The stage is in a modal when the one modal holds it on the model page, or #stagemodal
// when the page is embedded; either way it is the wider box with a memory of its own.
function cardMode(){ return modalHandle || modal.contains(stageEl) ? CARD.modal : CARD.page; }
// Nothing stored means half the box, not a fixed width: the two panes start equal and the
// reader decides from there. It is computed from the box in hand rather than carried as a
// number, so the page and the dialog each open even without either knowing the other's size.
function evenCard(){
var stage = stageEl.getBoundingClientRect().width;
var chrome = stage - stageEl.querySelector(".canvas").getBoundingClientRect().width - cardWidth();
return (stage - chrome) / 2;
}
function storedCard(){
var n = null;
try { n = parseInt(localStorage.getItem(cardMode().key), 10); } catch (e) {}
return n || evenCard();
}
var gutter = document.getElementById("gutter");
function cardWidth(){ return stageEl.querySelector(".card").getBoundingClientRect().width; }
function cardLimit(){
var stage = stageEl.getBoundingClientRect().width;
var canvas = stageEl.querySelector(".canvas").getBoundingClientRect().width;
// Everything between and around the two panes — the handle and the grid's gaps — measured
// rather than assumed. Subtracting a hard-coded handle width left the canvas 16px under
// its floor, because the grid has two gaps and the arithmetic knew about neither.
var chrome = stage - canvas - cardWidth();
// On a narrow box the floor wins and this collapses to one legal value, which is the
// honest answer rather than a negative one.
return Math.max(CARD_MIN, stage - CANVAS_MIN - chrome);
}
function setCard(px, remember){
var w = Math.round(Math.min(cardLimit(), Math.max(CARD_MIN, px)));
stageEl.style.setProperty("--card-w", w + "px");
if (remember) { try { localStorage.setItem(cardMode().key, String(w)); } catch (e) {} }
return w;
}
if (gutter) {
setCard(storedCard(), false);
var dragFrom = 0, dragWidth = 0;
gutter.addEventListener("pointerdown", function(ev){
dragFrom = ev.clientX; dragWidth = cardWidth();
gutter.setPointerCapture(ev.pointerId);
gutter.classList.add("dragging");
ev.preventDefault();
});
gutter.addEventListener("pointermove", function(ev){
if (!gutter.classList.contains("dragging")) return;
// The details pane is on the right, so dragging left widens it.
setCard(dragWidth - (ev.clientX - dragFrom), false);
render();
});
function endDrag(){
if (!gutter.classList.contains("dragging")) return;
gutter.classList.remove("dragging");
setCard(cardWidth(), true);
}
gutter.addEventListener("pointerup", endDrag);
gutter.addEventListener("pointercancel", endDrag);
// Double-click restores the default and forgets the stored one, the way a devtools split
// does — otherwise the only way back to the original is to drag until it looks right.
gutter.addEventListener("dblclick", function(){
try { localStorage.removeItem(cardMode().key); } catch (e) {}
setCard(evenCard(), false); render();
});
gutter.addEventListener("keydown", function(ev){
var step = ev.key === "ArrowLeft" ? 16 : ev.key === "ArrowRight" ? -16 : 0;
if (!step) return;
ev.preventDefault();
setCard(cardWidth() + step, true); render();
});
// On resize, re-apply what was asked for rather than what is currently shown: a width
// clamped down on a narrow window should come back when the window has room again. The
// stored number is the preference; the rendered one is only what last fitted.
window.addEventListener("resize", function(){ setCard(storedCard(), false); });
}
window.addEventListener("resize", function(){ if (focused) render(); });
// A hash may name an entity by its id, which is what a cite or an agent hands over; the
// stage's own hashes are addresses, so one that named an id is written back as the address
// it resolved to, in place, and the trail and every later comparison see one key per place.
function readHash(){