canon-atlas 0.1.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.
- package/LICENSE +21 -0
- package/README.md +103 -0
- package/package.json +46 -0
- package/src/build.mjs +37 -0
- package/src/cli.mjs +120 -0
- package/src/clusters.mjs +11 -0
- package/src/config.mjs +42 -0
- package/src/embed.mjs +179 -0
- package/src/graph.mjs +11 -0
- package/src/markdown.mjs +10 -0
- package/src/page.mjs +140 -0
- package/src/scan.mjs +37 -0
- package/src/serve.mjs +380 -0
- package/src/ui/app.js +1505 -0
- package/src/ui/labels.cjs +38 -0
- package/src/ui/pipeline.cjs +867 -0
- package/src/ui/resolve.cjs +104 -0
- package/src/ui/shell.js +829 -0
- package/src/ui/style.css +558 -0
- package/vendor/LICENSE.d3 +17 -0
- package/vendor/d3.v7.min.js +2 -0
package/src/ui/app.js
ADDED
|
@@ -0,0 +1,1505 @@
|
|
|
1
|
+
/* canon-atlas front end: the constellation, the reader, and in live mode the
|
|
2
|
+
editor. Runs against the globals d3 and DATA; DATA.mode is "chart" for a
|
|
3
|
+
static build and "live" when a server is behind the page.
|
|
4
|
+
|
|
5
|
+
The rendering is a port of the constellation view from the author's own
|
|
6
|
+
knowledge-graph tooling: a perceptual ramp sampled per cluster, lit-sphere
|
|
7
|
+
orbs over soft halos, per-edge gradients that fade from source hue to target
|
|
8
|
+
hue, and force anchors that pull each cluster into its own region. */
|
|
9
|
+
|
|
10
|
+
(function () {
|
|
11
|
+
"use strict";
|
|
12
|
+
|
|
13
|
+
var C = {
|
|
14
|
+
bg: "#090b09",
|
|
15
|
+
elev: "#0c0d09",
|
|
16
|
+
card: "#131409",
|
|
17
|
+
text: "#d4cfc3",
|
|
18
|
+
dim: "#8a8775",
|
|
19
|
+
bright: "#ede8db",
|
|
20
|
+
cream: "#e2dbc8",
|
|
21
|
+
green: "#7a8a2a",
|
|
22
|
+
greenBr: "#9aaa3a",
|
|
23
|
+
border: "#22231a",
|
|
24
|
+
borderL: "#33341f",
|
|
25
|
+
};
|
|
26
|
+
var FONT = "Outfit, system-ui, sans-serif";
|
|
27
|
+
|
|
28
|
+
/* Batlow with its two darkest navy stops dropped: on a near-black ground
|
|
29
|
+
they read as holes rather than nodes. */
|
|
30
|
+
var RAMP = ["#2f7268", "#4a7a52", "#6a7d44", "#9b8a3e", "#c79248", "#e89a6e", "#f2a091", "#f0aec6", "#e6b0e4"];
|
|
31
|
+
var rampScale = null;
|
|
32
|
+
function rampAt(t) {
|
|
33
|
+
if (!rampScale) {
|
|
34
|
+
rampScale = d3
|
|
35
|
+
.scaleLinear()
|
|
36
|
+
.domain(RAMP.map(function (_, i) { return i / (RAMP.length - 1); }))
|
|
37
|
+
.range(RAMP)
|
|
38
|
+
.interpolate(d3.interpolateHcl)
|
|
39
|
+
.clamp(true);
|
|
40
|
+
}
|
|
41
|
+
return t == null || isNaN(t) ? C.dim : rampScale(t);
|
|
42
|
+
}
|
|
43
|
+
function mix(base, top, alpha) {
|
|
44
|
+
var a = d3.rgb(base), b = d3.rgb(top);
|
|
45
|
+
return d3
|
|
46
|
+
.rgb(
|
|
47
|
+
Math.round(a.r + (b.r - a.r) * alpha),
|
|
48
|
+
Math.round(a.g + (b.g - a.g) * alpha),
|
|
49
|
+
Math.round(a.b + (b.b - a.b) * alpha)
|
|
50
|
+
)
|
|
51
|
+
.formatHex();
|
|
52
|
+
}
|
|
53
|
+
var NEUTRAL = "#3c3e33";
|
|
54
|
+
|
|
55
|
+
function el(tag, cls, text) {
|
|
56
|
+
var e = document.createElement(tag);
|
|
57
|
+
if (cls) e.className = cls;
|
|
58
|
+
if (text != null) e.textContent = text;
|
|
59
|
+
return e;
|
|
60
|
+
}
|
|
61
|
+
function clearHost(h) {
|
|
62
|
+
while (h.firstChild) h.removeChild(h.firstChild);
|
|
63
|
+
}
|
|
64
|
+
function reduced() {
|
|
65
|
+
return window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
66
|
+
}
|
|
67
|
+
/* Authored links draw solid, the primary thread of the constellation; a
|
|
68
|
+
frontmatter ref stays dashed so the two relations read apart at a glance. */
|
|
69
|
+
var EDGE = {
|
|
70
|
+
link: { dash: null, dist: 118, strength: 0.3, base: 0.5, w: 1.1 },
|
|
71
|
+
ref: { dash: "3 3", dist: 56, strength: 0.6, base: 0.4, w: 1 },
|
|
72
|
+
};
|
|
73
|
+
var VERB = { link: "links to", ref: "refers to" };
|
|
74
|
+
var INVERSE = { link: "linked from", ref: "referred to by" };
|
|
75
|
+
|
|
76
|
+
var stage = document.getElementById("stage");
|
|
77
|
+
/* The reader body; #side is the surrounding panel that also holds the tabs. */
|
|
78
|
+
var side = document.getElementById("reader");
|
|
79
|
+
var app = null;
|
|
80
|
+
/* Open documents, as paths, in the order opened. They live at module scope
|
|
81
|
+
so a live refresh or a remount keeps the tab row; stale paths are pruned
|
|
82
|
+
when a corpus loads. */
|
|
83
|
+
var openTabs = [];
|
|
84
|
+
var activeTab = null;
|
|
85
|
+
/* Opens a named sidebar panel; wireChrome installs the real function. */
|
|
86
|
+
var openPanel = function () {};
|
|
87
|
+
|
|
88
|
+
/* Editing and refresh go through a backend, so the same reader and editor
|
|
89
|
+
drive both the serve API and the pure-client File System Access adapter.
|
|
90
|
+
Each method returns a promise; graph() resolves to fresh wire data, the
|
|
91
|
+
doc methods to { path, raw } or nothing. A page that provides its own
|
|
92
|
+
window.AtlasBackend (the folder app does, once a directory is picked) swaps
|
|
93
|
+
it in at mount; otherwise the built-in HTTP backend talks to the server. */
|
|
94
|
+
function httpApi(method, url, body) {
|
|
95
|
+
return fetch(url, {
|
|
96
|
+
method: method,
|
|
97
|
+
headers: body ? { "Content-Type": "application/json" } : undefined,
|
|
98
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
99
|
+
}).then(function (res) {
|
|
100
|
+
return res.json().then(function (j) {
|
|
101
|
+
if (!res.ok) throw new Error(j.error || res.statusText);
|
|
102
|
+
return j;
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
var httpBackend = {
|
|
107
|
+
graph: function () { return httpApi("GET", "/api/graph"); },
|
|
108
|
+
readDoc: function (path) { return httpApi("GET", "/api/doc?path=" + encodeURIComponent(path)); },
|
|
109
|
+
writeDoc: function (path, content) { return httpApi("PUT", "/api/doc", { path: path, content: content }); },
|
|
110
|
+
createDoc: function (path, content) { return httpApi("POST", "/api/doc", { path: path, content: content }); },
|
|
111
|
+
deleteDoc: function (path) { return httpApi("DELETE", "/api/doc?path=" + encodeURIComponent(path)); },
|
|
112
|
+
};
|
|
113
|
+
var backend = httpBackend;
|
|
114
|
+
|
|
115
|
+
function init(data, keepPos) {
|
|
116
|
+
var G = shapeData(data, keepPos);
|
|
117
|
+
// Editing needs a writable backend: the server ("live") or a picked folder
|
|
118
|
+
// ("fs"). A built chart and a read-only folder browse ("browse") are static.
|
|
119
|
+
var live = data.mode === "live" || data.mode === "fs";
|
|
120
|
+
var cleanup = [];
|
|
121
|
+
|
|
122
|
+
AtlasLabels.assignTiers(G.nodes);
|
|
123
|
+
|
|
124
|
+
/* ── color modes ──────────────────────────────────────────────────── */
|
|
125
|
+
|
|
126
|
+
var clusterPos = {};
|
|
127
|
+
var clusterColor = {};
|
|
128
|
+
data.clusters.forEach(function (c) {
|
|
129
|
+
clusterPos[c.id] = c.pos;
|
|
130
|
+
clusterColor[c.id] = rampAt(c.pos);
|
|
131
|
+
});
|
|
132
|
+
var colPos = {};
|
|
133
|
+
data.collections.forEach(function (c, i) {
|
|
134
|
+
colPos[c.name] = data.collections.length > 1 ? i / (data.collections.length - 1) : 0.5;
|
|
135
|
+
});
|
|
136
|
+
var tagRank = {};
|
|
137
|
+
(function () {
|
|
138
|
+
var votes = {};
|
|
139
|
+
G.nodes.forEach(function (n) {
|
|
140
|
+
(n.tags || []).forEach(function (t) { votes[t] = (votes[t] || 0) + 1; });
|
|
141
|
+
});
|
|
142
|
+
var ranked = Object.keys(votes).sort(function (a, b) { return votes[b] - votes[a] || a.localeCompare(b); }).slice(0, 24);
|
|
143
|
+
ranked.forEach(function (t, i) {
|
|
144
|
+
tagRank[t] = ranked.length > 1 ? i / (ranked.length - 1) : 0.5;
|
|
145
|
+
});
|
|
146
|
+
})();
|
|
147
|
+
|
|
148
|
+
var colorMode = "cluster";
|
|
149
|
+
function nodeHue(n) {
|
|
150
|
+
if (!n.exists) return NEUTRAL;
|
|
151
|
+
if (colorMode === "collection") return rampAt(colPos[n.collection]);
|
|
152
|
+
if (colorMode === "tag") {
|
|
153
|
+
for (var i = 0; i < (n.tags || []).length; i++) {
|
|
154
|
+
if (tagRank[n.tags[i]] != null) return rampAt(tagRank[n.tags[i]]);
|
|
155
|
+
}
|
|
156
|
+
return mix(C.elev, C.dim, 0.75);
|
|
157
|
+
}
|
|
158
|
+
return n.cluster != null ? clusterColor[n.cluster] : mix(C.elev, C.dim, 0.75);
|
|
159
|
+
}
|
|
160
|
+
function recolor() {
|
|
161
|
+
G.nodes.forEach(function (n) { n.color = nodeHue(n); });
|
|
162
|
+
}
|
|
163
|
+
recolor();
|
|
164
|
+
|
|
165
|
+
/* ── svg scaffold ─────────────────────────────────────────────────── */
|
|
166
|
+
|
|
167
|
+
var svg = d3.select(stage).append("svg").style("display", "block").style("font-family", FONT).attr("tabindex", 0);
|
|
168
|
+
var defs = svg.append("defs");
|
|
169
|
+
|
|
170
|
+
var glow = defs.append("filter").attr("id", "at-glow")
|
|
171
|
+
.attr("x", "-80%").attr("y", "-80%").attr("width", "260%").attr("height", "260%");
|
|
172
|
+
glow.append("feGaussianBlur").attr("stdDeviation", "2.4").attr("result", "b");
|
|
173
|
+
var gm = glow.append("feMerge");
|
|
174
|
+
gm.append("feMergeNode").attr("in", "b");
|
|
175
|
+
gm.append("feMergeNode").attr("in", "SourceGraphic");
|
|
176
|
+
defs.append("filter").attr("id", "at-soft")
|
|
177
|
+
.attr("x", "-120%").attr("y", "-120%").attr("width", "340%").attr("height", "340%")
|
|
178
|
+
.append("feGaussianBlur").attr("stdDeviation", "7");
|
|
179
|
+
|
|
180
|
+
var wash = defs.append("radialGradient").attr("id", "at-wash")
|
|
181
|
+
.attr("cx", "42%").attr("cy", "30%").attr("r", "86%");
|
|
182
|
+
wash.append("stop").attr("offset", "0%").attr("stop-color", mix(C.elev, C.cream, 0.045));
|
|
183
|
+
wash.append("stop").attr("offset", "55%").attr("stop-color", mix(C.elev, C.cream, 0.018));
|
|
184
|
+
wash.append("stop").attr("offset", "100%").attr("stop-color", C.bg);
|
|
185
|
+
var vig = defs.append("radialGradient").attr("id", "at-vignette")
|
|
186
|
+
.attr("cx", "50%").attr("cy", "50%").attr("r", "74%");
|
|
187
|
+
vig.append("stop").attr("offset", "58%").attr("stop-color", "rgba(0,0,0,0)");
|
|
188
|
+
vig.append("stop").attr("offset", "100%").attr("stop-color", "rgba(0,0,0,0.42)");
|
|
189
|
+
var dotp = defs.append("pattern").attr("id", "at-dots")
|
|
190
|
+
.attr("width", 38).attr("height", 38).attr("patternUnits", "userSpaceOnUse");
|
|
191
|
+
dotp.append("circle").attr("cx", 1.5).attr("cy", 1.5).attr("r", 1).attr("fill", C.borderL).attr("opacity", 0.5);
|
|
192
|
+
|
|
193
|
+
/* One radial gradient per distinct color, built once and cached, so a node
|
|
194
|
+
reads as a lit sphere rather than a flat disc. */
|
|
195
|
+
var gradCache = {};
|
|
196
|
+
function orbFill(hex) {
|
|
197
|
+
var key = "atg" + hex.replace(/[^0-9a-z]/gi, "");
|
|
198
|
+
if (!gradCache[key]) {
|
|
199
|
+
var g = defs.append("radialGradient").attr("id", key)
|
|
200
|
+
.attr("cx", "38%").attr("cy", "35%").attr("r", "72%");
|
|
201
|
+
g.append("stop").attr("offset", "0%").attr("stop-color", d3.color(hex).brighter(1.05));
|
|
202
|
+
g.append("stop").attr("offset", "60%").attr("stop-color", hex);
|
|
203
|
+
g.append("stop").attr("offset", "100%").attr("stop-color", d3.color(hex).darker(0.55));
|
|
204
|
+
gradCache[key] = 1;
|
|
205
|
+
}
|
|
206
|
+
return "url(#" + key + ")";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
svg.append("rect").attr("width", "100%").attr("height", "100%").attr("fill", "url(#at-wash)");
|
|
210
|
+
var world = svg.append("g");
|
|
211
|
+
world.append("rect").attr("x", -8000).attr("y", -8000)
|
|
212
|
+
.attr("width", 16000).attr("height", 16000).attr("fill", "url(#at-dots)");
|
|
213
|
+
var linkLayer = world.append("g");
|
|
214
|
+
var flowLayer = world.append("g");
|
|
215
|
+
var haloLayer = world.append("g");
|
|
216
|
+
var nodeLayer = world.append("g");
|
|
217
|
+
var labelLayer = world.append("g");
|
|
218
|
+
svg.append("rect").attr("width", "100%").attr("height", "100%")
|
|
219
|
+
.attr("fill", "url(#at-vignette)").style("pointer-events", "none");
|
|
220
|
+
|
|
221
|
+
var adj = {}, outOf = {}, inOf = {};
|
|
222
|
+
var addr = {};
|
|
223
|
+
G.nodes.forEach(function (n) { adj[n.id] = {}; adj[n.id][n.id] = 1; outOf[n.id] = []; inOf[n.id] = []; });
|
|
224
|
+
G.nodes.forEach(function (n) { if (n.exists && n.address) addr[n.address] = n; });
|
|
225
|
+
G.edges.forEach(function (e) {
|
|
226
|
+
adj[e.source][e.target] = 1;
|
|
227
|
+
adj[e.target][e.source] = 1;
|
|
228
|
+
outOf[e.source].push({ id: e.target, via: e.via });
|
|
229
|
+
inOf[e.target].push({ id: e.source, via: e.via });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
var state = { hover: null, pinned: null, mode: {}, cluster: null, query: "", k: 1, W: 0, H: 0, warmed: false };
|
|
233
|
+
|
|
234
|
+
/* Visibility is the key row, nothing else: a collection is on the map
|
|
235
|
+
exactly when its row in the key is on. No reveal-on-select, no
|
|
236
|
+
reveal-by-search, nothing lingering; a hidden tier is out of the layout
|
|
237
|
+
too, so it claims no empty space. A collection configured reveal "off"
|
|
238
|
+
or "focus" starts hidden. */
|
|
239
|
+
var revealDefault = { unwritten: "always" };
|
|
240
|
+
data.collections.forEach(function (c) { revealDefault[c.name] = c.reveal || "always"; });
|
|
241
|
+
Object.keys(revealDefault).forEach(function (k) {
|
|
242
|
+
state.mode[k] = revealDefault[k] === "always" ? "on" : "off";
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
function chipKey(n) { return n.exists ? n.collection : "unwritten"; }
|
|
246
|
+
function nodeVisible(n) {
|
|
247
|
+
if ((state.mode[chipKey(n)] || "on") === "off") return false;
|
|
248
|
+
if (!n.exists) {
|
|
249
|
+
/* An unwritten name shows only while a visible document names it. */
|
|
250
|
+
var lists = outOf[n.id].concat(inOf[n.id]);
|
|
251
|
+
var anyVisible = lists.some(function (l) {
|
|
252
|
+
var d = G.nodes[l.id];
|
|
253
|
+
if ((state.mode[chipKey(d)] || "on") === "off") return false;
|
|
254
|
+
return state.cluster == null || d.cluster === state.cluster;
|
|
255
|
+
});
|
|
256
|
+
if (!anyVisible) return false;
|
|
257
|
+
}
|
|
258
|
+
if (state.cluster != null && n.cluster !== state.cluster) return false;
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
function edgeVisible(e) { return nodeVisible(e.source) && nodeVisible(e.target); }
|
|
262
|
+
/* The simulation holds exactly what the map shows. */
|
|
263
|
+
function simActive(n) { return nodeVisible(n); }
|
|
264
|
+
/* A soft node is a whole revealed tier shown at once: present, lower weight. */
|
|
265
|
+
function soft(n) {
|
|
266
|
+
var k = chipKey(n);
|
|
267
|
+
return revealDefault[k] !== "always" && state.mode[k] === "on";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function matches(n) {
|
|
271
|
+
if (!state.query) return true;
|
|
272
|
+
return n._search.indexOf(state.query) >= 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function radius(n) {
|
|
276
|
+
var r = 5.5 + Math.sqrt(n.degree || 0) * 2.5;
|
|
277
|
+
if (!n.exists) return Math.min(7, r * 0.8);
|
|
278
|
+
return r;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* Per-edge gradient, so a link fades from its source hue to its target
|
|
282
|
+
hue. Past a few thousand edges the gradient defs and their per-tick
|
|
283
|
+
endpoint updates dominate the mount (30,011 defs measured at 10k docs),
|
|
284
|
+
so a dense corpus draws flat source-tinted strokes instead. */
|
|
285
|
+
var FLAT_EDGES = G.edges.length > 4000;
|
|
286
|
+
G.edges.forEach(function (e, i) {
|
|
287
|
+
if (!FLAT_EDGES) {
|
|
288
|
+
var lg = defs.append("linearGradient").attr("id", "atl" + i).attr("gradientUnits", "userSpaceOnUse");
|
|
289
|
+
e._s0 = lg.append("stop").attr("offset", "0%").node();
|
|
290
|
+
e._s1 = lg.append("stop").attr("offset", "100%").node();
|
|
291
|
+
e._g = lg.node();
|
|
292
|
+
e._gid = "url(#atl" + i + ")";
|
|
293
|
+
}
|
|
294
|
+
e._fo = (i * 0.6180339887) % 1;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
var linkForce = d3.forceLink(G.edges).id(function (d) { return d.id; })
|
|
298
|
+
.distance(function (e) { return EDGE[e.via].dist; })
|
|
299
|
+
.strength(function (e) { return EDGE[e.via].strength; });
|
|
300
|
+
var sim = d3.forceSimulation(G.nodes)
|
|
301
|
+
.force("link", linkForce)
|
|
302
|
+
.force("charge", d3.forceManyBody().strength(-320).distanceMax(560))
|
|
303
|
+
.force("collide", d3.forceCollide().radius(function (n) { return radius(n) + 11; }).strength(0.86))
|
|
304
|
+
.on("tick", tick)
|
|
305
|
+
.stop();
|
|
306
|
+
|
|
307
|
+
/* Rebind the simulation to the active set. Hidden tiers must not tug the
|
|
308
|
+
layout, or four articles would float in the empty space their hundred
|
|
309
|
+
hidden satellites reserve. */
|
|
310
|
+
function rebindSim(alpha) {
|
|
311
|
+
sim.nodes(G.nodes.filter(simActive));
|
|
312
|
+
linkForce.links(G.edges.filter(function (e) { return simActive(e.source) && simActive(e.target); }));
|
|
313
|
+
if (alpha) sim.alpha(alpha).restart();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/* Each cluster gets an angle on a ring, so a theme becomes a region. An
|
|
317
|
+
unclustered node is pulled gently to the middle instead. */
|
|
318
|
+
var anchor = {};
|
|
319
|
+
function computeAnchors() {
|
|
320
|
+
var R = Math.min(state.W, state.H) * 0.34;
|
|
321
|
+
data.clusters.forEach(function (c, i) {
|
|
322
|
+
var a = (i / data.clusters.length) * 2 * Math.PI - Math.PI / 2;
|
|
323
|
+
anchor[c.id] = [state.W / 2 + R * Math.cos(a), state.H / 2 + R * Math.sin(a)];
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
function ax(d) { return d.cluster != null && anchor[d.cluster] ? anchor[d.cluster][0] : state.W / 2; }
|
|
327
|
+
function ay(d) { return d.cluster != null && anchor[d.cluster] ? anchor[d.cluster][1] : state.H / 2; }
|
|
328
|
+
function astr(d) { return d.cluster != null ? 0.06 : 0.02; }
|
|
329
|
+
|
|
330
|
+
var linkSel = linkLayer.selectAll("path").data(G.edges).join("path")
|
|
331
|
+
.attr("fill", "none")
|
|
332
|
+
.attr("stroke", function (e) { return e._gid || null; })
|
|
333
|
+
.attr("stroke-dasharray", function (e) { return EDGE[e.via].dash; });
|
|
334
|
+
|
|
335
|
+
var flowSel = flowLayer.selectAll("circle").data(G.edges).join("circle")
|
|
336
|
+
.attr("r", 2.6).style("opacity", 0).style("pointer-events", "none");
|
|
337
|
+
|
|
338
|
+
var haloSel = haloLayer.selectAll("circle")
|
|
339
|
+
.data(G.nodes.filter(function (n) { return n.exists && !n.immutable; })).join("circle")
|
|
340
|
+
.attr("r", function (n) { return radius(n) * 2.1; })
|
|
341
|
+
.attr("filter", "url(#at-soft)")
|
|
342
|
+
.style("opacity", 0).style("pointer-events", "none");
|
|
343
|
+
|
|
344
|
+
var dragMoved = false;
|
|
345
|
+
var nodeSel = nodeLayer.selectAll("g").data(G.nodes).join("g")
|
|
346
|
+
.attr("class", "node")
|
|
347
|
+
.attr("tabindex", 0).attr("role", "button")
|
|
348
|
+
.attr("aria-label", nodeAria)
|
|
349
|
+
.on("mouseenter", function (e, n) { state.hover = n; paint(); })
|
|
350
|
+
.on("mouseleave", function () { state.hover = null; paint(); })
|
|
351
|
+
.on("focus", function (e, n) { state.hover = n; paint(); })
|
|
352
|
+
.on("blur", function () { state.hover = null; paint(); })
|
|
353
|
+
.on("click", function (e, n) { e.stopPropagation(); if (!dragMoved) pin(n); })
|
|
354
|
+
.on("keydown", function (e, n) {
|
|
355
|
+
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pin(n); }
|
|
356
|
+
})
|
|
357
|
+
.call(d3.drag()
|
|
358
|
+
.filter(function (e, n) { return !e.ctrlKey && !e.button && simActive(n); })
|
|
359
|
+
/* The simulation heats on the first actual movement, never on the
|
|
360
|
+
press: heating at mousedown lurched the whole sky, the node drifted
|
|
361
|
+
out from under the cursor before mouseup, and the browser resolved
|
|
362
|
+
the click to the background, which cleared the selection. */
|
|
363
|
+
.on("start", function (e, n) {
|
|
364
|
+
dragMoved = false;
|
|
365
|
+
n.fx = n.x; n.fy = n.y;
|
|
366
|
+
})
|
|
367
|
+
.on("drag", function (e, n) {
|
|
368
|
+
if (!dragMoved) {
|
|
369
|
+
dragMoved = true;
|
|
370
|
+
sim.alphaTarget(0.2).restart();
|
|
371
|
+
}
|
|
372
|
+
n.fx = e.x; n.fy = e.y;
|
|
373
|
+
})
|
|
374
|
+
.on("end", function (e, n) {
|
|
375
|
+
if (dragMoved) sim.alphaTarget(0);
|
|
376
|
+
/* A pinned node stays held where the drag left it. */
|
|
377
|
+
if (state.pinned === n) { n.fx = n.x; n.fy = n.y; }
|
|
378
|
+
else { n.fx = null; n.fy = null; }
|
|
379
|
+
}));
|
|
380
|
+
|
|
381
|
+
function drawShapes() {
|
|
382
|
+
nodeSel.each(function (n) {
|
|
383
|
+
var g = d3.select(this);
|
|
384
|
+
g.selectAll("*").remove();
|
|
385
|
+
var r = radius(n);
|
|
386
|
+
if (!n.exists) {
|
|
387
|
+
g.append("circle").attr("class", "nd").attr("r", r)
|
|
388
|
+
.attr("fill", mix(C.bg, NEUTRAL, 0.6))
|
|
389
|
+
.attr("stroke", "#5c5e4d").attr("stroke-width", 1).attr("stroke-dasharray", "3 2.5");
|
|
390
|
+
} else if (n.immutable) {
|
|
391
|
+
g.append("circle").attr("class", "nd").attr("r", r)
|
|
392
|
+
.attr("fill", d3.color(n.color).copy({ opacity: 0.16 }))
|
|
393
|
+
.attr("stroke", n.color).attr("stroke-width", 1.8);
|
|
394
|
+
} else {
|
|
395
|
+
g.append("circle").attr("class", "nd").attr("r", r)
|
|
396
|
+
.attr("fill", orbFill(n.color))
|
|
397
|
+
.attr("stroke", d3.color(n.color).brighter(0.6)).attr("stroke-width", 1)
|
|
398
|
+
.style("filter", "url(#at-glow)");
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
haloSel.attr("fill", function (n) { return n.color; });
|
|
402
|
+
flowSel.attr("fill", function (e) { return G.nodes[e.source.id != null ? e.source.id : e.source].color; });
|
|
403
|
+
if (FLAT_EDGES) {
|
|
404
|
+
linkSel.attr("stroke", function (e) {
|
|
405
|
+
return G.nodes[e.source.id != null ? e.source.id : e.source].color;
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
drawShapes();
|
|
410
|
+
|
|
411
|
+
var ring = nodeLayer.append("circle").attr("fill", "none").attr("stroke", C.cream)
|
|
412
|
+
.attr("stroke-width", 1.2).attr("stroke-dasharray", "3 3")
|
|
413
|
+
.style("opacity", 0).style("pointer-events", "none");
|
|
414
|
+
|
|
415
|
+
var labelSel = labelLayer.selectAll("text").data(G.nodes).join("text")
|
|
416
|
+
.attr("class", "lbl")
|
|
417
|
+
.attr("text-anchor", "middle").attr("paint-order", "stroke")
|
|
418
|
+
.attr("stroke", C.bg).attr("stroke-width", 2.4).attr("stroke-linejoin", "round")
|
|
419
|
+
.text(function (n) { return n._label; });
|
|
420
|
+
function tintLabels() {
|
|
421
|
+
labelSel.attr("fill", function (n) {
|
|
422
|
+
return n.exists ? d3.hcl(d3.color(n.color)).brighter(0.9) + "" : C.dim;
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
tintLabels();
|
|
426
|
+
|
|
427
|
+
svg.on("click", function () { clearPin(); });
|
|
428
|
+
|
|
429
|
+
function measureLabels() {
|
|
430
|
+
labelSel.each(function (n) {
|
|
431
|
+
var fs = labelSize(n);
|
|
432
|
+
this.style.fontSize = fs + "px";
|
|
433
|
+
try { n._lw = this.getComputedTextLength(); } catch (e) { n._lw = n._label.length * fs * 0.5; }
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function nodeAria(n) {
|
|
438
|
+
if (!n.exists) return "Unwritten reference " + n.title + ", named but not yet a document.";
|
|
439
|
+
return n.collection + " " + n.title + ", " + n.degree + " connections.";
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/* ── side panel ───────────────────────────────────────────────────── */
|
|
443
|
+
|
|
444
|
+
function relRow(rel, dir) {
|
|
445
|
+
var n = G.nodes[rel.id];
|
|
446
|
+
var b = el("button", "rel");
|
|
447
|
+
b.type = "button";
|
|
448
|
+
var dot = el("span", "reldot");
|
|
449
|
+
if (!n.exists) { dot.style.border = "1px dashed #5c5e4d"; dot.style.background = "none"; }
|
|
450
|
+
else if (n.immutable) { dot.style.border = "1.5px solid " + n.color; dot.style.background = "none"; }
|
|
451
|
+
else dot.style.background = n.color;
|
|
452
|
+
b.appendChild(dot);
|
|
453
|
+
b.appendChild(el("span", "relverb", dir === "out" ? VERB[rel.via] : INVERSE[rel.via]));
|
|
454
|
+
b.appendChild(el("span", "relname", n.exists ? n.address : n.title));
|
|
455
|
+
b.addEventListener("click", function (e) { e.stopPropagation(); pin(n); });
|
|
456
|
+
return b;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/* The documents panel: the search box (the one search: it filters this
|
|
460
|
+
list and lights the matches on the map), an order select, and every
|
|
461
|
+
document, click to read. */
|
|
462
|
+
var WIKI_ROWS = 400;
|
|
463
|
+
var wikiSearch = null;
|
|
464
|
+
var wikiOrder = "rank";
|
|
465
|
+
function renderWiki() {
|
|
466
|
+
var host = document.getElementById("wiki");
|
|
467
|
+
if (!host) return;
|
|
468
|
+
clearHost(host);
|
|
469
|
+
var all = G.nodes.filter(function (n) { return n.exists; });
|
|
470
|
+
var input = el("input", "wk-filter");
|
|
471
|
+
input.type = "search";
|
|
472
|
+
input.placeholder = "search " + all.length + " documents ( / )";
|
|
473
|
+
wikiSearch = input;
|
|
474
|
+
host.appendChild(input);
|
|
475
|
+
var bar = el("div", "wk-bar");
|
|
476
|
+
var order = el("select", "abtn");
|
|
477
|
+
order.setAttribute("aria-label", "order documents by");
|
|
478
|
+
[["rank", "order: rank"], ["recent", "order: recent"], ["title", "order: title"]].forEach(function (o) {
|
|
479
|
+
var opt = el("option", null, o[1]);
|
|
480
|
+
opt.value = o[0];
|
|
481
|
+
order.appendChild(opt);
|
|
482
|
+
});
|
|
483
|
+
order.value = wikiOrder;
|
|
484
|
+
bar.appendChild(order);
|
|
485
|
+
var countNote = el("span", "wk-note", "");
|
|
486
|
+
bar.appendChild(countNote);
|
|
487
|
+
host.appendChild(bar);
|
|
488
|
+
var listHost = el("div");
|
|
489
|
+
host.appendChild(listHost);
|
|
490
|
+
var ORDERS = {
|
|
491
|
+
rank: function (a, b) { return b.rank - a.rank; },
|
|
492
|
+
recent: function (a, b) { return (b.date || "").localeCompare(a.date || "") || b.rank - a.rank; },
|
|
493
|
+
title: function (a, b) { return a.title.localeCompare(b.title); },
|
|
494
|
+
};
|
|
495
|
+
function renderList() {
|
|
496
|
+
clearHost(listHost);
|
|
497
|
+
state.query = input.value.trim().toLowerCase();
|
|
498
|
+
paint();
|
|
499
|
+
var q = state.query;
|
|
500
|
+
var ranked = all.slice().sort(ORDERS[wikiOrder] || ORDERS.rank);
|
|
501
|
+
var hits = q ? ranked.filter(function (n) { return n._search.indexOf(q) >= 0; }) : ranked;
|
|
502
|
+
countNote.textContent = q ? hits.length + " match" + (hits.length === 1 ? "" : "es") : "";
|
|
503
|
+
hits.slice(0, WIKI_ROWS).forEach(function (n) {
|
|
504
|
+
var row = el("button", "wk-row");
|
|
505
|
+
row.type = "button";
|
|
506
|
+
var d = el("span", "reldot");
|
|
507
|
+
d.style.background = n.color;
|
|
508
|
+
row.appendChild(d);
|
|
509
|
+
var t = el("span", "wk-title", n.title);
|
|
510
|
+
t.title = n.address;
|
|
511
|
+
row.appendChild(t);
|
|
512
|
+
row.appendChild(el("span", "wk-rank", wikiOrder === "recent" && n.date ? String(n.date).slice(0, 10) : n.collection));
|
|
513
|
+
row.addEventListener("click", function () { pin(n); });
|
|
514
|
+
listHost.appendChild(row);
|
|
515
|
+
});
|
|
516
|
+
if (hits.length > WIKI_ROWS) {
|
|
517
|
+
listHost.appendChild(el("div", "wk-note",
|
|
518
|
+
"top " + WIKI_ROWS + " of " + hits.length + "; search to reach the rest"));
|
|
519
|
+
}
|
|
520
|
+
if (q && !hits.length) listHost.appendChild(el("div", "wk-note", "no documents match"));
|
|
521
|
+
}
|
|
522
|
+
input.addEventListener("input", renderList);
|
|
523
|
+
input.addEventListener("keydown", function (e) {
|
|
524
|
+
if (e.key === "Enter" && state.query) {
|
|
525
|
+
var q = state.query;
|
|
526
|
+
var pool = all.filter(function (n) { return n._search.indexOf(q) >= 0; })
|
|
527
|
+
.sort(function (a, b) { return b.rank - a.rank; });
|
|
528
|
+
var top = pool.filter(nodeVisible)[0] || pool[0];
|
|
529
|
+
if (top) pin(top);
|
|
530
|
+
}
|
|
531
|
+
if (e.key === "Escape") {
|
|
532
|
+
input.value = "";
|
|
533
|
+
renderList();
|
|
534
|
+
input.blur();
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
order.addEventListener("change", function () { wikiOrder = order.value; renderList(); });
|
|
538
|
+
renderList();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/* The map panel: corpus stats, color mode, cluster isolation, and the key,
|
|
542
|
+
whose rows double as each collection's on/off switch. */
|
|
543
|
+
function renderOverview() {
|
|
544
|
+
var ov = document.getElementById("overview");
|
|
545
|
+
if (!ov) return;
|
|
546
|
+
clearHost(ov);
|
|
547
|
+
ov.appendChild(el("p", "side-kind", (data.title || "canon-atlas") + " · " + modeLabel(data.mode)));
|
|
548
|
+
var real = G.nodes.filter(function (n) { return n.exists; });
|
|
549
|
+
ov.appendChild(el("p", "side-title", real.length + " documents, " + G.edges.length + " references"));
|
|
550
|
+
var basisLine = {
|
|
551
|
+
embeddings: "Color is the semantic cluster a document belongs to, computed from its embedding, and the layout pulls each cluster into its own region.",
|
|
552
|
+
paths: "Color is the top of a document's address, so each first-level path is a region of the map.",
|
|
553
|
+
links: "Color is the neighborhood a document belongs to in the link structure, and the layout pulls each neighborhood into its own region.",
|
|
554
|
+
none: "No clusters yet: add links between documents, or provide embeddings, and regions will form.",
|
|
555
|
+
}[data.basis];
|
|
556
|
+
ov.appendChild(el("p", "side-meta", basisLine + " Select a cluster to isolate it, or any node to read it."));
|
|
557
|
+
data.collections.forEach(function (c) {
|
|
558
|
+
if (state.mode[c.name] === "off" && c.count) {
|
|
559
|
+
ov.appendChild(el("p", "side-meta",
|
|
560
|
+
c.count + " " + c.name + " document" + (c.count === 1 ? " is" : "s are") +
|
|
561
|
+
" off the map; the " + c.name + " row in the key turns them on."));
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
var crow = el("div", "edit-row");
|
|
565
|
+
var csel = el("select", "abtn");
|
|
566
|
+
csel.setAttribute("aria-label", "color by");
|
|
567
|
+
[["cluster", "color: cluster"], ["collection", "color: collection"], ["tag", "color: tag"]].forEach(function (o) {
|
|
568
|
+
var opt = el("option", null, o[1]);
|
|
569
|
+
opt.value = o[0];
|
|
570
|
+
csel.appendChild(opt);
|
|
571
|
+
});
|
|
572
|
+
// A corpus without tags has nothing for tag color to say; the option
|
|
573
|
+
// disables rather than painting everything the same grey.
|
|
574
|
+
var anyTags = G.nodes.some(function (n) { return n.exists && n.tags && n.tags.length; });
|
|
575
|
+
csel.querySelector('option[value="tag"]').disabled = !anyTags;
|
|
576
|
+
if (!anyTags && colorMode === "tag") colorMode = "cluster";
|
|
577
|
+
csel.value = colorMode;
|
|
578
|
+
csel.addEventListener("change", function () {
|
|
579
|
+
colorMode = csel.value;
|
|
580
|
+
recolor();
|
|
581
|
+
drawShapes();
|
|
582
|
+
tintLabels();
|
|
583
|
+
tick();
|
|
584
|
+
paint();
|
|
585
|
+
});
|
|
586
|
+
crow.appendChild(csel);
|
|
587
|
+
ov.appendChild(crow);
|
|
588
|
+
if (data.clusters.length) {
|
|
589
|
+
var key = el("div");
|
|
590
|
+
data.clusters.forEach(function (c) {
|
|
591
|
+
var row = el("button", "clrow" + (state.cluster === c.id ? " is-on" : ""));
|
|
592
|
+
row.type = "button";
|
|
593
|
+
var d = el("span", "reldot");
|
|
594
|
+
d.style.background = clusterColor[c.id];
|
|
595
|
+
row.appendChild(d);
|
|
596
|
+
row.appendChild(el("span", null, AtlasLabels.truncateLabel(c.label)));
|
|
597
|
+
row.appendChild(el("span", "clcount", String(c.size)));
|
|
598
|
+
row.addEventListener("click", function (e) {
|
|
599
|
+
e.stopPropagation();
|
|
600
|
+
state.cluster = state.cluster === c.id ? null : c.id;
|
|
601
|
+
releasePin();
|
|
602
|
+
rebindSim(0.3);
|
|
603
|
+
paint();
|
|
604
|
+
renderOverview();
|
|
605
|
+
fitToView(true);
|
|
606
|
+
});
|
|
607
|
+
key.appendChild(row);
|
|
608
|
+
});
|
|
609
|
+
ov.appendChild(el("p", "side-sec", "clusters"));
|
|
610
|
+
ov.appendChild(key);
|
|
611
|
+
}
|
|
612
|
+
ov.appendChild(el("p", "side-sec", "key"));
|
|
613
|
+
var shapes = el("div");
|
|
614
|
+
var rows = [];
|
|
615
|
+
data.collections.forEach(function (c) {
|
|
616
|
+
rows.push([c.immutable ? "ring" : "orb", c.name, c.immutable ? "immutable, never rewritten" : "mutable, current ground truth", c.count]);
|
|
617
|
+
});
|
|
618
|
+
if (G.nodes.some(function (n) { return !n.exists; })) {
|
|
619
|
+
rows.push(["dashed", "unwritten", "referenced but not yet a document", G.nodes.filter(function (n) { return !n.exists; }).length]);
|
|
620
|
+
}
|
|
621
|
+
rows.forEach(function (r) {
|
|
622
|
+
var name = r[1];
|
|
623
|
+
var on = state.mode[name] === "on";
|
|
624
|
+
var row = el("button", "clrow keyrow" + (on ? "" : " is-off"));
|
|
625
|
+
row.type = "button";
|
|
626
|
+
row.title = name + (on ? ": on the map, click to hide" : ": hidden, click to show");
|
|
627
|
+
row.setAttribute("aria-pressed", String(on));
|
|
628
|
+
var d = el("span", "reldot");
|
|
629
|
+
var neutral = mix(C.elev, C.cream, 0.62);
|
|
630
|
+
if (r[0] === "orb") d.style.background = neutral;
|
|
631
|
+
else if (r[0] === "ring") { d.style.border = "1.5px solid " + neutral; d.style.background = "none"; }
|
|
632
|
+
else { d.style.border = "1px dashed #5c5e4d"; d.style.background = "none"; }
|
|
633
|
+
row.appendChild(d);
|
|
634
|
+
row.appendChild(el("span", null, name));
|
|
635
|
+
var note = el("span", "clcount", r[3] + " " + r[2]);
|
|
636
|
+
note.style.marginLeft = "auto";
|
|
637
|
+
note.style.textAlign = "right";
|
|
638
|
+
row.appendChild(note);
|
|
639
|
+
row.addEventListener("click", function () {
|
|
640
|
+
state.mode[name] = state.mode[name] === "on" ? "off" : "on";
|
|
641
|
+
rebindSim(0.4);
|
|
642
|
+
paint();
|
|
643
|
+
renderOverview();
|
|
644
|
+
fitToView(true);
|
|
645
|
+
});
|
|
646
|
+
shapes.appendChild(row);
|
|
647
|
+
});
|
|
648
|
+
ov.appendChild(shapes);
|
|
649
|
+
if (live) {
|
|
650
|
+
var mk = el("div", "edit-row");
|
|
651
|
+
var nb = el("button", "abtn", "new document");
|
|
652
|
+
nb.addEventListener("click", function () { showReader(); renderCreate(); });
|
|
653
|
+
mk.appendChild(nb);
|
|
654
|
+
ov.appendChild(mk);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function wireDocLinks(container, sourceNode) {
|
|
659
|
+
container.querySelectorAll("a.wl").forEach(function (a) {
|
|
660
|
+
var t = a.getAttribute("data-target") || "";
|
|
661
|
+
var via = a.getAttribute("data-via") || "wikilink";
|
|
662
|
+
var hit = G.resolve(t, sourceNode ? sourceNode.path : "", via);
|
|
663
|
+
if (hit) {
|
|
664
|
+
a.addEventListener("click", function (e) { e.preventDefault(); pin(hit); });
|
|
665
|
+
} else {
|
|
666
|
+
a.classList.add("dead");
|
|
667
|
+
a.title = "no document by this name";
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/* ── reader tabs ──────────────────────────────────────────────────── */
|
|
673
|
+
|
|
674
|
+
function byPath(p) {
|
|
675
|
+
return G.nodes.filter(function (n) { return n.exists && n.path === p; })[0] || null;
|
|
676
|
+
}
|
|
677
|
+
var tabsHost = document.getElementById("tabs");
|
|
678
|
+
function showReader() {
|
|
679
|
+
document.getElementById("grid").classList.remove("side-off");
|
|
680
|
+
window.dispatchEvent(new Event("resize"));
|
|
681
|
+
}
|
|
682
|
+
function hideReader() {
|
|
683
|
+
document.getElementById("grid").classList.add("side-off");
|
|
684
|
+
window.dispatchEvent(new Event("resize"));
|
|
685
|
+
}
|
|
686
|
+
function renderTabs() {
|
|
687
|
+
if (!tabsHost) return;
|
|
688
|
+
clearHost(tabsHost);
|
|
689
|
+
openTabs.forEach(function (p) {
|
|
690
|
+
var n = byPath(p);
|
|
691
|
+
if (!n) return;
|
|
692
|
+
var t = el("button", "tab" + (activeTab === p ? " is-on" : ""));
|
|
693
|
+
t.type = "button";
|
|
694
|
+
t.title = n.address || n.title;
|
|
695
|
+
var d = el("span", "reldot");
|
|
696
|
+
if (n.immutable) { d.style.border = "1.5px solid " + n.color; d.style.background = "none"; }
|
|
697
|
+
else d.style.background = n.color;
|
|
698
|
+
t.appendChild(d);
|
|
699
|
+
t.appendChild(el("span", "tab-title", n.title));
|
|
700
|
+
var x = el("span", "tab-x", "×");
|
|
701
|
+
x.title = "close";
|
|
702
|
+
x.addEventListener("click", function (e) { e.stopPropagation(); closeTab(p); });
|
|
703
|
+
t.appendChild(x);
|
|
704
|
+
t.addEventListener("click", function () { pin(n); });
|
|
705
|
+
t.addEventListener("auxclick", function (e) {
|
|
706
|
+
if (e.button === 1) { e.preventDefault(); closeTab(p); }
|
|
707
|
+
});
|
|
708
|
+
tabsHost.appendChild(t);
|
|
709
|
+
});
|
|
710
|
+
var on = tabsHost.querySelector(".tab.is-on");
|
|
711
|
+
if (on && on.scrollIntoView) on.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
712
|
+
}
|
|
713
|
+
/* Every document opens into its own tab; opening again just activates it.
|
|
714
|
+
A node that is not a document (an unwritten name) shows in the reader
|
|
715
|
+
without claiming a tab. */
|
|
716
|
+
function openDocTab(n) {
|
|
717
|
+
if (!n.exists || !n.path) {
|
|
718
|
+
activeTab = null;
|
|
719
|
+
renderTabs();
|
|
720
|
+
renderNode(n);
|
|
721
|
+
showReader();
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
if (openTabs.indexOf(n.path) < 0) openTabs.push(n.path);
|
|
725
|
+
activeTab = n.path;
|
|
726
|
+
renderTabs();
|
|
727
|
+
renderNode(n);
|
|
728
|
+
showReader();
|
|
729
|
+
}
|
|
730
|
+
function closeTab(p) {
|
|
731
|
+
var i = openTabs.indexOf(p);
|
|
732
|
+
if (i >= 0) openTabs.splice(i, 1);
|
|
733
|
+
if (activeTab !== p) { renderTabs(); return; }
|
|
734
|
+
var next = openTabs[Math.min(i, openTabs.length - 1)];
|
|
735
|
+
var n = next && byPath(next);
|
|
736
|
+
if (n) { pin(n); return; }
|
|
737
|
+
activeTab = null;
|
|
738
|
+
renderTabs();
|
|
739
|
+
clearHost(side);
|
|
740
|
+
hideReader();
|
|
741
|
+
clearPin();
|
|
742
|
+
}
|
|
743
|
+
/* Back to the active tab after a transient view (create form, an error). */
|
|
744
|
+
function showActive() {
|
|
745
|
+
var n = activeTab && byPath(activeTab);
|
|
746
|
+
if (n) { renderNode(n); return; }
|
|
747
|
+
clearHost(side);
|
|
748
|
+
hideReader();
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function renderNode(n) {
|
|
752
|
+
clearHost(side);
|
|
753
|
+
/* Breadcrumbs for structured addresses: each parent segment is a step
|
|
754
|
+
up, landing on the document at that address when one exists and
|
|
755
|
+
otherwise filtering the search to everything under it. */
|
|
756
|
+
if (n.exists && n.address && n.address.indexOf("/") > 0) {
|
|
757
|
+
var crumbs = el("p", "crumbs");
|
|
758
|
+
var segs = n.address.split("/");
|
|
759
|
+
var acc = "";
|
|
760
|
+
segs.forEach(function (seg, i) {
|
|
761
|
+
if (i) crumbs.appendChild(el("span", "crumb-sep", "/"));
|
|
762
|
+
acc = acc ? acc + "/" + seg : seg;
|
|
763
|
+
if (i === segs.length - 1) { crumbs.appendChild(el("span", "crumb-here", seg)); return; }
|
|
764
|
+
var target = acc;
|
|
765
|
+
var b = el("button", "crumb", seg);
|
|
766
|
+
b.addEventListener("click", function () {
|
|
767
|
+
var hit = addr[target];
|
|
768
|
+
if (hit) { pin(hit); return; }
|
|
769
|
+
openPanel("wiki");
|
|
770
|
+
if (wikiSearch) {
|
|
771
|
+
wikiSearch.value = target + "/";
|
|
772
|
+
wikiSearch.dispatchEvent(new Event("input"));
|
|
773
|
+
wikiSearch.focus();
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
crumbs.appendChild(b);
|
|
777
|
+
});
|
|
778
|
+
side.appendChild(crumbs);
|
|
779
|
+
}
|
|
780
|
+
var kind = n.exists ? n.collection + (n.immutable ? ", immutable" : "") : "unwritten reference";
|
|
781
|
+
side.appendChild(el("p", "side-kind", kind));
|
|
782
|
+
var t = el("p", "side-title", n.title);
|
|
783
|
+
if (n.exists) t.style.color = d3.hcl(d3.color(n.color)).brighter(1.1) + "";
|
|
784
|
+
side.appendChild(t);
|
|
785
|
+
if (n.path) side.appendChild(el("p", "side-path", n.path + (n.date ? " · " + n.date : "")));
|
|
786
|
+
if (n.sourcePath) side.appendChild(el("p", "side-path", "source: " + n.sourcePath));
|
|
787
|
+
if (!n.exists) {
|
|
788
|
+
side.appendChild(el("p", "side-meta",
|
|
789
|
+
"Named by " + inOf[n.id].length + " document" + (inOf[n.id].length === 1 ? "" : "s") +
|
|
790
|
+
" but not yet written. The constellation keeps the empty seat visible."));
|
|
791
|
+
}
|
|
792
|
+
if (n.summary) {
|
|
793
|
+
var cap = el("p", "side-capsule", n.summary);
|
|
794
|
+
if (n.exists) cap.style.borderLeftColor = n.color;
|
|
795
|
+
side.appendChild(cap);
|
|
796
|
+
}
|
|
797
|
+
if (n.tags && n.tags.length) {
|
|
798
|
+
var tw = el("div", "tags");
|
|
799
|
+
n.tags.forEach(function (tag) {
|
|
800
|
+
var c = el("span", "tag", tag);
|
|
801
|
+
if (n.exists) {
|
|
802
|
+
c.style.borderColor = mix(C.elev, n.color, 0.55);
|
|
803
|
+
c.style.color = mix(C.elev, n.color, 0.9);
|
|
804
|
+
}
|
|
805
|
+
tw.appendChild(c);
|
|
806
|
+
});
|
|
807
|
+
side.appendChild(tw);
|
|
808
|
+
}
|
|
809
|
+
if (live && n.exists) {
|
|
810
|
+
var row = el("div", "edit-row");
|
|
811
|
+
if (!n.immutable) {
|
|
812
|
+
var eb = el("button", "abtn", "edit");
|
|
813
|
+
eb.addEventListener("click", function () { renderEditor(n); });
|
|
814
|
+
row.appendChild(eb);
|
|
815
|
+
var db = el("button", "abtn danger", "delete");
|
|
816
|
+
var armed = false, disarm = 0;
|
|
817
|
+
db.addEventListener("click", function () {
|
|
818
|
+
if (!armed) {
|
|
819
|
+
/* Two clicks to delete, and the arming wears off, so a stray
|
|
820
|
+
click minutes later cannot land on a loaded button. */
|
|
821
|
+
armed = true;
|
|
822
|
+
db.textContent = "really delete?";
|
|
823
|
+
disarm = setTimeout(function () { armed = false; db.textContent = "delete"; }, 4000);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
clearTimeout(disarm);
|
|
827
|
+
backend.deleteDoc(n.path).then(function () {
|
|
828
|
+
state.pinned = null;
|
|
829
|
+
pushHash(null);
|
|
830
|
+
refresh();
|
|
831
|
+
}).catch(showErr);
|
|
832
|
+
});
|
|
833
|
+
row.appendChild(db);
|
|
834
|
+
} else {
|
|
835
|
+
row.appendChild(el("span", "side-meta", "immutable: the tool never edits or deletes this collection"));
|
|
836
|
+
}
|
|
837
|
+
side.appendChild(row);
|
|
838
|
+
}
|
|
839
|
+
if (live && !n.exists) {
|
|
840
|
+
var mkRow = el("div", "edit-row");
|
|
841
|
+
var wb = el("button", "abtn", "write it");
|
|
842
|
+
wb.addEventListener("click", function () { renderCreate(n.title); });
|
|
843
|
+
mkRow.appendChild(wb);
|
|
844
|
+
side.appendChild(mkRow);
|
|
845
|
+
}
|
|
846
|
+
if (n.html) {
|
|
847
|
+
side.appendChild(el("p", "side-sec", "document"));
|
|
848
|
+
var doc = el("div", "doc");
|
|
849
|
+
doc.innerHTML = n.html;
|
|
850
|
+
side.appendChild(doc);
|
|
851
|
+
wireDocLinks(doc, n);
|
|
852
|
+
}
|
|
853
|
+
var outs = outOf[n.id], ins = inOf[n.id];
|
|
854
|
+
if (outs.length) {
|
|
855
|
+
side.appendChild(el("p", "side-sec", "points at " + outs.length));
|
|
856
|
+
outs.forEach(function (r) { side.appendChild(relRow(r, "out")); });
|
|
857
|
+
}
|
|
858
|
+
if (ins.length) {
|
|
859
|
+
side.appendChild(el("p", "side-sec", "pointed at by " + ins.length));
|
|
860
|
+
ins.forEach(function (r) { side.appendChild(relRow(r, "in")); });
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function showErr(err) {
|
|
865
|
+
var p = el("p", "err", String((err && err.message) || err));
|
|
866
|
+
side.insertBefore(p, side.firstChild);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function renderEditor(n) {
|
|
870
|
+
backend.readDoc(n.path).then(function (doc) {
|
|
871
|
+
clearHost(side);
|
|
872
|
+
side.appendChild(el("p", "side-kind", "editing " + n.collection));
|
|
873
|
+
side.appendChild(el("p", "side-path", n.path));
|
|
874
|
+
var area = el("textarea", "edit-area");
|
|
875
|
+
area.value = doc.raw;
|
|
876
|
+
side.appendChild(area);
|
|
877
|
+
var row = el("div", "edit-row");
|
|
878
|
+
var save = el("button", "abtn", "save");
|
|
879
|
+
save.addEventListener("click", function () {
|
|
880
|
+
backend.writeDoc(n.path, area.value).then(function () {
|
|
881
|
+
refresh(n.path);
|
|
882
|
+
}).catch(showErr);
|
|
883
|
+
});
|
|
884
|
+
var cancel = el("button", "abtn", "cancel");
|
|
885
|
+
cancel.addEventListener("click", function () { renderNode(n); });
|
|
886
|
+
row.appendChild(save);
|
|
887
|
+
row.appendChild(cancel);
|
|
888
|
+
side.appendChild(row);
|
|
889
|
+
area.focus();
|
|
890
|
+
}).catch(showErr);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/* The collection a path would land in: longest matching prefix claims it,
|
|
894
|
+
the same rule the pipeline sorts files by. */
|
|
895
|
+
function collFor(p) {
|
|
896
|
+
var best = null;
|
|
897
|
+
data.collections.forEach(function (c) {
|
|
898
|
+
if (p.indexOf(c.match) === 0 && (!best || c.match.length > best.match.length)) best = c;
|
|
899
|
+
});
|
|
900
|
+
return best;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/* A new document should read like its siblings: frontmatter keys the
|
|
904
|
+
collection's documents actually carry (under the corpus's own names),
|
|
905
|
+
and a title heading. Majority rules; a bare notes folder stays bare. */
|
|
906
|
+
function templateFor(coll, seedTitle) {
|
|
907
|
+
var f = (coll && coll.fields) || {};
|
|
908
|
+
var sibs = coll
|
|
909
|
+
? G.nodes.filter(function (n) { return n.exists && n.collection === coll.name; })
|
|
910
|
+
: [];
|
|
911
|
+
function most(has) {
|
|
912
|
+
var c = 0;
|
|
913
|
+
sibs.forEach(function (n) { if (has(n)) c++; });
|
|
914
|
+
return c * 2 > sibs.length;
|
|
915
|
+
}
|
|
916
|
+
var lines = [];
|
|
917
|
+
if (sibs.length) {
|
|
918
|
+
if (most(function (n) { return n.summary; })) lines.push((f.summary || "summary") + ": ");
|
|
919
|
+
if (most(function (n) { return n.date; })) {
|
|
920
|
+
lines.push((f.date || "date") + ": " + new Date().toISOString().slice(0, 10));
|
|
921
|
+
}
|
|
922
|
+
if (most(function (n) { return n.tags && n.tags.length; })) lines.push((f.tags || "tags") + ": []");
|
|
923
|
+
/* Only a corpus that renamed the refs key (journal's "subject") writes
|
|
924
|
+
refs in frontmatter; the default name means body links carry them. */
|
|
925
|
+
if (f.refs && f.refs !== "refs") lines.push(f.refs + ": []");
|
|
926
|
+
}
|
|
927
|
+
var fm = lines.length ? "---\n" + lines.join("\n") + "\n---\n\n" : "";
|
|
928
|
+
return fm + "# " + (seedTitle || "") + "\n";
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function renderCreate(seedTitle) {
|
|
932
|
+
clearHost(side);
|
|
933
|
+
side.appendChild(el("p", "side-kind", "new document"));
|
|
934
|
+
var mutable = data.collections.filter(function (c) { return !c.immutable; });
|
|
935
|
+
var prefix = mutable.length && data.preset !== "default" ? (mutable[0].match || "") : "";
|
|
936
|
+
var path = el("input", "edit-path");
|
|
937
|
+
path.value = prefix + (seedTitle ? seedTitle.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") : "") + (seedTitle ? ".md" : "");
|
|
938
|
+
path.placeholder = prefix + "name.md";
|
|
939
|
+
side.appendChild(path);
|
|
940
|
+
var area = el("textarea", "edit-area");
|
|
941
|
+
area.value = templateFor(collFor(path.value.trim()) || mutable[0] || null, seedTitle);
|
|
942
|
+
var touched = false;
|
|
943
|
+
area.addEventListener("input", function () { touched = true; });
|
|
944
|
+
path.addEventListener("input", function () {
|
|
945
|
+
if (!touched) area.value = templateFor(collFor(path.value.trim()) || mutable[0] || null, seedTitle);
|
|
946
|
+
});
|
|
947
|
+
side.appendChild(area);
|
|
948
|
+
var row = el("div", "edit-row");
|
|
949
|
+
var save = el("button", "abtn", "create");
|
|
950
|
+
save.addEventListener("click", function () {
|
|
951
|
+
backend.createDoc(path.value.trim(), area.value).then(function () {
|
|
952
|
+
refresh(path.value.trim());
|
|
953
|
+
}).catch(showErr);
|
|
954
|
+
});
|
|
955
|
+
var cancel = el("button", "abtn", "cancel");
|
|
956
|
+
cancel.addEventListener("click", function () { showActive(); });
|
|
957
|
+
row.appendChild(save);
|
|
958
|
+
row.appendChild(cancel);
|
|
959
|
+
side.appendChild(row);
|
|
960
|
+
path.focus();
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/* ── focus, zoom, fit ─────────────────────────────────────────────── */
|
|
964
|
+
|
|
965
|
+
function pin(n) {
|
|
966
|
+
if (state.pinned && state.pinned !== n) { state.pinned.fx = null; state.pinned.fy = null; }
|
|
967
|
+
state.pinned = n;
|
|
968
|
+
/* Hold the selected node still: what you are reading must not drift. */
|
|
969
|
+
if (n.x != null) { n.fx = n.x; n.fy = n.y; }
|
|
970
|
+
paint();
|
|
971
|
+
openDocTab(n);
|
|
972
|
+
if (nodeVisible(n)) centreOn(n);
|
|
973
|
+
if (n.exists && n.path) pushHash(n.path);
|
|
974
|
+
}
|
|
975
|
+
function centreOn(n) {
|
|
976
|
+
if (n.x == null) return;
|
|
977
|
+
var k = Math.max(state.k || 1, 1.15);
|
|
978
|
+
var target = function () {
|
|
979
|
+
return d3.zoomIdentity.translate(state.W / 2 - k * n.x, state.H / 2 - k * n.y).scale(k);
|
|
980
|
+
};
|
|
981
|
+
if (reduced()) { svg.call(zoom.transform, target()); return; }
|
|
982
|
+
/* The pinned node is held still, so the flight's aim is stable; the end
|
|
983
|
+
snap re-reads position and stage size, covering anything that moved
|
|
984
|
+
or resized mid-flight. (Never re-apply zoom.transform per frame from
|
|
985
|
+
inside a tween: on a plain selection it interrupts its own flight.) */
|
|
986
|
+
svg.transition().duration(650).ease(d3.easeCubicInOut).call(zoom.transform, target)
|
|
987
|
+
.on("end", function () { svg.call(zoom.transform, target()); });
|
|
988
|
+
}
|
|
989
|
+
function releasePin() {
|
|
990
|
+
if (state.pinned) { state.pinned.fx = null; state.pinned.fy = null; }
|
|
991
|
+
state.pinned = null;
|
|
992
|
+
}
|
|
993
|
+
function clearPin() {
|
|
994
|
+
if (!state.pinned) return;
|
|
995
|
+
releasePin();
|
|
996
|
+
paint();
|
|
997
|
+
pushHash(null);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/* ── label placement ──────────────────────────────────────────────── */
|
|
1001
|
+
|
|
1002
|
+
var CAND = [
|
|
1003
|
+
{ dx: 0, dy: -1, anchor: "middle" }, { dx: 0, dy: 1, anchor: "middle" },
|
|
1004
|
+
{ dx: 1, dy: 0, anchor: "start" }, { dx: -1, dy: 0, anchor: "end" },
|
|
1005
|
+
{ dx: 1, dy: -1, anchor: "start" }, { dx: -1, dy: -1, anchor: "end" },
|
|
1006
|
+
{ dx: 1, dy: 1, anchor: "start" }, { dx: -1, dy: 1, anchor: "end" },
|
|
1007
|
+
];
|
|
1008
|
+
function labelSize(n) { return n.exists ? 10.5 : 8; }
|
|
1009
|
+
function offX(n, a, k) { var d = radius(n) + 5 / k; return a.dx * (a.dy ? d * 0.72 : d); }
|
|
1010
|
+
function offY(n, a, k) {
|
|
1011
|
+
var d = radius(n) + 5 / k, fs = labelSize(n) / k;
|
|
1012
|
+
if (!a.dy) return fs * 0.34;
|
|
1013
|
+
if (a.dy < 0) return -(a.dx ? d * 0.72 : d);
|
|
1014
|
+
return (a.dx ? d * 0.72 : d) + fs * 0.85;
|
|
1015
|
+
}
|
|
1016
|
+
function candBox(n, a, cx, cy, w, h, k) {
|
|
1017
|
+
var bx = cx + offX(n, a, k) * k, by = cy + offY(n, a, k) * k;
|
|
1018
|
+
var x0 = a.anchor === "middle" ? bx - w / 2 : a.anchor === "start" ? bx : bx - w;
|
|
1019
|
+
return [x0, by - h * 0.8, x0 + w, by + h * 0.2];
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function paint() {
|
|
1023
|
+
var f = state.hover || state.pinned;
|
|
1024
|
+
if (f && !nodeVisible(f)) f = null;
|
|
1025
|
+
var k = state.k || 1;
|
|
1026
|
+
var near = f ? adj[f.id] : null;
|
|
1027
|
+
var q = !!state.query;
|
|
1028
|
+
|
|
1029
|
+
function dimmed(n) {
|
|
1030
|
+
if (near && !near[n.id]) return true;
|
|
1031
|
+
if (q && !matches(n)) return true;
|
|
1032
|
+
return false;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
nodeSel.style("display", function (n) { return nodeVisible(n) ? null : "none"; })
|
|
1036
|
+
.style("opacity", function (n) { return dimmed(n) ? 0.11 : soft(n) ? 0.68 : 1; });
|
|
1037
|
+
haloSel.style("display", function (n) { return nodeVisible(n) ? null : "none"; })
|
|
1038
|
+
.style("opacity", function (n) { return dimmed(n) ? 0.02 : near ? 0.3 : 0.15; });
|
|
1039
|
+
|
|
1040
|
+
linkSel.style("display", function (e) { return edgeVisible(e) ? null : "none"; })
|
|
1041
|
+
.attr("stroke-width", function (e) {
|
|
1042
|
+
var on = f && (e.source.id === f.id || e.target.id === f.id);
|
|
1043
|
+
return (on ? EDGE[e.via].w + 0.9 : EDGE[e.via].w) / k;
|
|
1044
|
+
})
|
|
1045
|
+
.attr("stroke-opacity", function (e) {
|
|
1046
|
+
var base = EDGE[e.via].base;
|
|
1047
|
+
if (q && (!matches(e.source) || !matches(e.target))) return 0.04;
|
|
1048
|
+
if (!f) return base;
|
|
1049
|
+
if (e.source.id === f.id || e.target.id === f.id) return Math.min(1, base + 0.36);
|
|
1050
|
+
return 0.04;
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
var show = {};
|
|
1054
|
+
var t = d3.zoomTransform(svg.node());
|
|
1055
|
+
var placed = [];
|
|
1056
|
+
var pool = G.nodes.filter(function (n) { return nodeVisible(n) && !dimmed(n); });
|
|
1057
|
+
pool.forEach(function (n) {
|
|
1058
|
+
var r = radius(n) * t.k;
|
|
1059
|
+
var sx = n.x * t.k + t.x, sy = n.y * t.k + t.y;
|
|
1060
|
+
placed.push([sx - r, sy - r, sx + r, sy + r]);
|
|
1061
|
+
});
|
|
1062
|
+
pool.slice().sort(function (a, b) {
|
|
1063
|
+
if (f) { if (a === f) return -1; if (b === f) return 1; }
|
|
1064
|
+
return b.degree - a.degree;
|
|
1065
|
+
}).forEach(function (n) {
|
|
1066
|
+
if (!f && !q && t.k < n._minK) return;
|
|
1067
|
+
/* Labels render at a screen-constant size (font-size is labelSize/k
|
|
1068
|
+
inside a group scaled by k), so the reservation box must be screen
|
|
1069
|
+
constant too, or zooming in blockades placement with phantom height. */
|
|
1070
|
+
var w = (n._lw || n._label.length * labelSize(n) * 0.5) + 9, h = labelSize(n) * 1.2 + 5;
|
|
1071
|
+
var cx = n.x * t.k + t.x, cy = n.y * t.k + t.y;
|
|
1072
|
+
for (var c = 0; c < CAND.length; c++) {
|
|
1073
|
+
var box = candBox(n, CAND[c], cx, cy, w, h, t.k);
|
|
1074
|
+
var clear = box[0] >= 3 && box[2] <= state.W - 3 && box[1] >= 3 && box[3] <= state.H - 3;
|
|
1075
|
+
for (var i = 0; clear && i < placed.length; i++) {
|
|
1076
|
+
var p = placed[i];
|
|
1077
|
+
if (box[0] < p[2] && box[2] > p[0] && box[1] < p[3] && box[3] > p[1]) clear = false;
|
|
1078
|
+
}
|
|
1079
|
+
if (clear) { placed.push(box); show[n.id] = 1; n._at = CAND[c]; return; }
|
|
1080
|
+
}
|
|
1081
|
+
/* The focused node and the sky's anchors (tier zero, the biggest)
|
|
1082
|
+
keep their names even when crowded; later labels avoid their box. */
|
|
1083
|
+
if (f === n || n._minK === 0) {
|
|
1084
|
+
placed.push(candBox(n, CAND[0], cx, cy, w, h, t.k));
|
|
1085
|
+
show[n.id] = 1;
|
|
1086
|
+
n._at = CAND[0];
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
labelSel
|
|
1090
|
+
.style("display", function (n) { return nodeVisible(n) ? null : "none"; })
|
|
1091
|
+
.attr("text-anchor", function (n) { return (n._at || CAND[0]).anchor; })
|
|
1092
|
+
.attr("dx", function (n) { return offX(n, n._at || CAND[0], k); })
|
|
1093
|
+
.attr("dy", function (n) { return offY(n, n._at || CAND[0], k); })
|
|
1094
|
+
.style("font-size", function (n) { return labelSize(n) / k + "px"; })
|
|
1095
|
+
.style("stroke-width", 2.4 / k + "px")
|
|
1096
|
+
.style("opacity", function (n) { return show[n.id] ? (dimmed(n) ? 0.16 : 1) : 0; });
|
|
1097
|
+
|
|
1098
|
+
G.edges.forEach(function (e) {
|
|
1099
|
+
if (!edgeVisible(e)) { e._flowOp = 0; return; }
|
|
1100
|
+
if (q && (!matches(e.source) || !matches(e.target))) { e._flowOp = 0; return; }
|
|
1101
|
+
if (!f) { e._flowOp = 0.22; return; }
|
|
1102
|
+
e._flowOp = e.source.id === f.id || e.target.id === f.id ? 0.85 : 0;
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
if (state.pinned && nodeVisible(state.pinned)) {
|
|
1106
|
+
ring.attr("cx", state.pinned.x).attr("cy", state.pinned.y)
|
|
1107
|
+
.attr("r", radius(state.pinned) + 5).attr("stroke-width", 1.2 / k).style("opacity", 0.9);
|
|
1108
|
+
} else ring.style("opacity", 0);
|
|
1109
|
+
|
|
1110
|
+
/* The sky can be dragged or zoomed clean out of view; when no visible
|
|
1111
|
+
node is on screen, offer the way back where the eye is looking. */
|
|
1112
|
+
var lostBtn = document.getElementById("lost");
|
|
1113
|
+
if (lostBtn) {
|
|
1114
|
+
var anyOn = false;
|
|
1115
|
+
for (var li = 0; li < G.nodes.length && !anyOn; li++) {
|
|
1116
|
+
var ln = G.nodes[li];
|
|
1117
|
+
if (ln.x == null || !nodeVisible(ln)) continue;
|
|
1118
|
+
var lx = ln.x * t.k + t.x, ly = ln.y * t.k + t.y;
|
|
1119
|
+
if (lx > -20 && lx < state.W + 20 && ly > -20 && ly < state.H + 20) anyOn = true;
|
|
1120
|
+
}
|
|
1121
|
+
lostBtn.hidden = anyOn;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function curve(e) {
|
|
1126
|
+
var x1 = e.source.x, y1 = e.source.y, x2 = e.target.x, y2 = e.target.y;
|
|
1127
|
+
var dx = x2 - x1, dy = y2 - y1;
|
|
1128
|
+
return "M" + x1 + "," + y1 + "Q" + ((x1 + x2) / 2 - dy * 0.1) + "," + ((y1 + y2) / 2 + dx * 0.1) + " " + x2 + "," + y2;
|
|
1129
|
+
}
|
|
1130
|
+
function flowPoint(e, t) {
|
|
1131
|
+
var x1 = e.source.x, y1 = e.source.y, x2 = e.target.x, y2 = e.target.y;
|
|
1132
|
+
var dx = x2 - x1, dy = y2 - y1;
|
|
1133
|
+
var cx = (x1 + x2) / 2 - dy * 0.1, cy = (y1 + y2) / 2 + dx * 0.1;
|
|
1134
|
+
var u = 1 - t;
|
|
1135
|
+
return [u * u * x1 + 2 * u * t * cx + t * t * x2, u * u * y1 + 2 * u * t * cy + t * t * y2];
|
|
1136
|
+
}
|
|
1137
|
+
function tick() {
|
|
1138
|
+
linkSel.attr("d", curve);
|
|
1139
|
+
if (!FLAT_EDGES) {
|
|
1140
|
+
G.edges.forEach(function (e) {
|
|
1141
|
+
e._g.setAttribute("x1", e.source.x);
|
|
1142
|
+
e._g.setAttribute("y1", e.source.y);
|
|
1143
|
+
e._g.setAttribute("x2", e.target.x);
|
|
1144
|
+
e._g.setAttribute("y2", e.target.y);
|
|
1145
|
+
e._s0.setAttribute("stop-color", e.source.color);
|
|
1146
|
+
e._s1.setAttribute("stop-color", e.target.color);
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
haloSel.attr("cx", function (n) { return n.x; }).attr("cy", function (n) { return n.y; });
|
|
1150
|
+
nodeSel.attr("transform", function (n) { return "translate(" + n.x + "," + n.y + ")"; });
|
|
1151
|
+
labelSel.attr("x", function (n) { return n.x; }).attr("y", function (n) { return n.y; });
|
|
1152
|
+
if (state.pinned) ring.attr("cx", state.pinned.x).attr("cy", state.pinned.y);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
var zoom = d3.zoom().scaleExtent([0.2, 5])
|
|
1156
|
+
.filter(function (e) { return e.type === "wheel" || !e.button; })
|
|
1157
|
+
.on("zoom", function (e) {
|
|
1158
|
+
world.attr("transform", e.transform);
|
|
1159
|
+
state.k = e.transform.k;
|
|
1160
|
+
paint();
|
|
1161
|
+
});
|
|
1162
|
+
svg.call(zoom).on("dblclick.zoom", null);
|
|
1163
|
+
/* Double-clicking the sky reframes it: the always-there way home. */
|
|
1164
|
+
svg.on("dblclick", function () { fitToView(true); });
|
|
1165
|
+
|
|
1166
|
+
function fitToView(animate) {
|
|
1167
|
+
var vis = G.nodes.filter(nodeVisible);
|
|
1168
|
+
if (!vis.length) return;
|
|
1169
|
+
var minX = d3.min(vis, function (n) { return n.x; }), maxX = d3.max(vis, function (n) { return n.x; });
|
|
1170
|
+
var minY = d3.min(vis, function (n) { return n.y; }), maxY = d3.max(vis, function (n) { return n.y; });
|
|
1171
|
+
var pad = 74;
|
|
1172
|
+
var s = Math.min(2.2, Math.max(0.25,
|
|
1173
|
+
Math.min((state.W - pad) / Math.max(maxX - minX, 1), (state.H - pad) / Math.max(maxY - minY, 1))));
|
|
1174
|
+
var t = d3.zoomIdentity
|
|
1175
|
+
.translate(state.W / 2 - s * (minX + maxX) / 2, state.H / 2 - s * (minY + maxY) / 2)
|
|
1176
|
+
.scale(s);
|
|
1177
|
+
if (animate && !reduced()) svg.transition().duration(650).ease(d3.easeCubicInOut).call(zoom.transform, t);
|
|
1178
|
+
else svg.call(zoom.transform, t);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
var raf = null, PERIOD = 3400, BREATH = 5200;
|
|
1182
|
+
function frame(now) {
|
|
1183
|
+
var phase = (now % PERIOD) / PERIOD, k = state.k || 1;
|
|
1184
|
+
/* The sky breathes: one slow opacity swell over the whole link layer,
|
|
1185
|
+
a single write per frame whatever the corpus size. */
|
|
1186
|
+
linkLayer.attr("opacity", 0.9 + 0.1 * Math.sin(((now % BREATH) / BREATH) * 2 * Math.PI));
|
|
1187
|
+
flowSel.each(function (e) {
|
|
1188
|
+
var o = e._flowOp || 0;
|
|
1189
|
+
if (o <= 0.02 || e.source.x == null) { this.style.opacity = 0; return; }
|
|
1190
|
+
var t = (phase + e._fo) % 1, p = flowPoint(e, t);
|
|
1191
|
+
this.setAttribute("cx", p[0]);
|
|
1192
|
+
this.setAttribute("cy", p[1]);
|
|
1193
|
+
this.setAttribute("r", 2.6 / k);
|
|
1194
|
+
this.style.opacity = o * (0.3 + 0.7 * Math.sin(Math.PI * t));
|
|
1195
|
+
});
|
|
1196
|
+
raf = requestAnimationFrame(frame);
|
|
1197
|
+
}
|
|
1198
|
+
function startFlow() { if (reduced() || raf != null) return; raf = requestAnimationFrame(frame); }
|
|
1199
|
+
function stopFlow() {
|
|
1200
|
+
if (raf != null) { cancelAnimationFrame(raf); raf = null; }
|
|
1201
|
+
flowSel.style("opacity", 0);
|
|
1202
|
+
linkLayer.attr("opacity", 1);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/* ── header wiring ────────────────────────────────────────────────── */
|
|
1206
|
+
|
|
1207
|
+
var refit = document.getElementById("refit");
|
|
1208
|
+
function onRefit() {
|
|
1209
|
+
state.cluster = null;
|
|
1210
|
+
releasePin();
|
|
1211
|
+
paint();
|
|
1212
|
+
renderOverview();
|
|
1213
|
+
fitToView(true);
|
|
1214
|
+
pushHash(null);
|
|
1215
|
+
}
|
|
1216
|
+
refit.addEventListener("click", onRefit);
|
|
1217
|
+
cleanup.push(function () { refit.removeEventListener("click", onRefit); });
|
|
1218
|
+
|
|
1219
|
+
var lost = document.getElementById("lost");
|
|
1220
|
+
function onLost() { fitToView(true); paint(); }
|
|
1221
|
+
if (lost) {
|
|
1222
|
+
lost.addEventListener("click", onLost);
|
|
1223
|
+
cleanup.push(function () { lost.removeEventListener("click", onLost); });
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function onDocKey(e) {
|
|
1227
|
+
if (/INPUT|TEXTAREA|SELECT/.test(e.target.tagName)) return;
|
|
1228
|
+
if (e.key === "/" || e.key === "f") {
|
|
1229
|
+
e.preventDefault();
|
|
1230
|
+
openPanel("wiki");
|
|
1231
|
+
if (wikiSearch) wikiSearch.focus();
|
|
1232
|
+
}
|
|
1233
|
+
if (e.key === "Escape") clearPin();
|
|
1234
|
+
}
|
|
1235
|
+
document.addEventListener("keydown", onDocKey);
|
|
1236
|
+
cleanup.push(function () { document.removeEventListener("keydown", onDocKey); });
|
|
1237
|
+
|
|
1238
|
+
/* ── live refresh ─────────────────────────────────────────────────── */
|
|
1239
|
+
|
|
1240
|
+
function refresh(focusPath) {
|
|
1241
|
+
backend.graph().then(function (fresh) {
|
|
1242
|
+
var pos = {};
|
|
1243
|
+
G.nodes.forEach(function (n) { if (n.x != null) pos[n.exists ? n.path : "?" + n.title.toLowerCase()] = { x: n.x, y: n.y }; });
|
|
1244
|
+
destroy();
|
|
1245
|
+
app = init(fresh, pos);
|
|
1246
|
+
if (focusPath) {
|
|
1247
|
+
var hit = app.G.nodes.filter(function (n) { return n.path === focusPath; })[0];
|
|
1248
|
+
if (hit) app.pin(hit);
|
|
1249
|
+
}
|
|
1250
|
+
}).catch(showErr);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/* ── boot ─────────────────────────────────────────────────────────── */
|
|
1254
|
+
|
|
1255
|
+
function size() {
|
|
1256
|
+
state.W = stage.clientWidth;
|
|
1257
|
+
state.H = stage.clientHeight;
|
|
1258
|
+
svg.attr("viewBox", "0 0 " + state.W + " " + state.H).attr("width", state.W).attr("height", state.H);
|
|
1259
|
+
computeAnchors();
|
|
1260
|
+
sim.force("x", d3.forceX(ax).strength(astr)).force("y", d3.forceY(ay).strength(astr));
|
|
1261
|
+
}
|
|
1262
|
+
/* A panel opening or a grip drag resizes the stage; the camera holds its
|
|
1263
|
+
place instead of snapping to fit, so opening a panel never yanks the sky
|
|
1264
|
+
out from under you. Refit stays a deliberate act. */
|
|
1265
|
+
function onResize() {
|
|
1266
|
+
size();
|
|
1267
|
+
paint();
|
|
1268
|
+
}
|
|
1269
|
+
window.addEventListener("resize", onResize);
|
|
1270
|
+
cleanup.push(function () { window.removeEventListener("resize", onResize); });
|
|
1271
|
+
|
|
1272
|
+
size();
|
|
1273
|
+
rebindSim();
|
|
1274
|
+
/* Warm-up cost is ticks times nodes; a dense corpus converges enough in
|
|
1275
|
+
fewer passes, and the ticks saved are the difference between a mount
|
|
1276
|
+
and a coffee break. */
|
|
1277
|
+
var active = sim.nodes().length;
|
|
1278
|
+
var WARM = active > 6000 ? 180 : active > 2000 ? 300 : 520;
|
|
1279
|
+
for (var i = 0; i < WARM; i++) sim.tick();
|
|
1280
|
+
/* A tier waiting on reveal never warmed, so park each such document beside
|
|
1281
|
+
a settled neighbor it will bloom from; physics takes over on reveal. Two
|
|
1282
|
+
passes let a satellite whose only neighbor is another satellite park
|
|
1283
|
+
beside that neighbor's parked position, not its pre-warm one. The golden
|
|
1284
|
+
angle keeps every parking spot distinct, because a search reveal shows
|
|
1285
|
+
these positions verbatim with no collision force to separate stacks. */
|
|
1286
|
+
(function () {
|
|
1287
|
+
var parked = {};
|
|
1288
|
+
var GOLDEN = 2.39996322972865;
|
|
1289
|
+
for (var pass = 0; pass < 2; pass++) {
|
|
1290
|
+
G.nodes.forEach(function (n) {
|
|
1291
|
+
if (simActive(n) || parked[n.id]) return;
|
|
1292
|
+
var lists = outOf[n.id].concat(inOf[n.id]);
|
|
1293
|
+
var host = null;
|
|
1294
|
+
for (var i = 0; i < lists.length && !host; i++) {
|
|
1295
|
+
var m = G.nodes[lists[i].id];
|
|
1296
|
+
if (simActive(m) || parked[m.id]) host = m;
|
|
1297
|
+
}
|
|
1298
|
+
if (!host && pass === 0) return;
|
|
1299
|
+
var a = n.id * GOLDEN;
|
|
1300
|
+
var r = 34 + (n.id % 9) * 6;
|
|
1301
|
+
n.x = (host && host.x != null ? host.x : state.W / 2) + Math.cos(a) * r;
|
|
1302
|
+
n.y = (host && host.y != null ? host.y : state.H / 2) + Math.sin(a) * r;
|
|
1303
|
+
parked[n.id] = 1;
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
})();
|
|
1307
|
+
tick();
|
|
1308
|
+
measureLabels();
|
|
1309
|
+
renderOverview();
|
|
1310
|
+
renderWiki();
|
|
1311
|
+
/* Restore the tab row: stale paths (a deleted document, another corpus)
|
|
1312
|
+
drop out, and the reader shows only when something is open. */
|
|
1313
|
+
openTabs = openTabs.filter(function (p) { return byPath(p); });
|
|
1314
|
+
if (activeTab && !byPath(activeTab)) activeTab = openTabs.length ? openTabs[openTabs.length - 1] : null;
|
|
1315
|
+
renderTabs();
|
|
1316
|
+
if (activeTab) { renderNode(byPath(activeTab)); showReader(); }
|
|
1317
|
+
else { clearHost(side); hideReader(); }
|
|
1318
|
+
fitToView(false);
|
|
1319
|
+
paint();
|
|
1320
|
+
startFlow();
|
|
1321
|
+
if (document.fonts && document.fonts.ready) {
|
|
1322
|
+
document.fonts.ready.then(function () { measureLabels(); paint(); });
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function destroy() {
|
|
1326
|
+
stopFlow();
|
|
1327
|
+
sim.stop();
|
|
1328
|
+
cleanup.forEach(function (fn) { fn(); });
|
|
1329
|
+
svg.remove();
|
|
1330
|
+
clearHost(side);
|
|
1331
|
+
["wiki", "overview", "tabs"].forEach(function (id) {
|
|
1332
|
+
var h = document.getElementById(id);
|
|
1333
|
+
if (h) clearHost(h);
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
return {
|
|
1338
|
+
G: G,
|
|
1339
|
+
pin: pin,
|
|
1340
|
+
clearPin: clearPin,
|
|
1341
|
+
destroy: destroy,
|
|
1342
|
+
refresh: refresh,
|
|
1343
|
+
byPath: byPath,
|
|
1344
|
+
pinnedPath: function () { return (state.pinned && state.pinned.path) || null; },
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/* Turn the wire DATA into live structures: nodes get search text and a
|
|
1349
|
+
resolver for reader wikilinks, and previous positions carry over so a
|
|
1350
|
+
refresh does not reshuffle the sky. */
|
|
1351
|
+
function shapeData(data, keepPos) {
|
|
1352
|
+
var nodes = data.nodes;
|
|
1353
|
+
var strip = document.createElement("div");
|
|
1354
|
+
nodes.forEach(function (n) {
|
|
1355
|
+
strip.innerHTML = n.html || "";
|
|
1356
|
+
n._label = AtlasLabels.truncateLabel(n.title);
|
|
1357
|
+
n._search = (n.title + " " + (n.address || "") + " " + (n.sourcePath || "") + " " + (n.tags || []).join(" ") + " " + strip.textContent).toLowerCase();
|
|
1358
|
+
if (keepPos) {
|
|
1359
|
+
var p = keepPos[n.exists ? n.path : "?" + n.title.toLowerCase()];
|
|
1360
|
+
if (p) { n.x = p.x; n.y = p.y; }
|
|
1361
|
+
}
|
|
1362
|
+
});
|
|
1363
|
+
strip.innerHTML = "";
|
|
1364
|
+
/* The same resolver the server built the edges with, so a reader link
|
|
1365
|
+
lands exactly where its edge points, or is dead where the edge is. */
|
|
1366
|
+
var index = AtlasResolve.buildIndex(nodes.filter(function (n) { return n.exists; }));
|
|
1367
|
+
function resolve(target, sourcePath, via) {
|
|
1368
|
+
var hit = AtlasResolve.resolveLink(index, sourcePath || "", target, via || "wikilink");
|
|
1369
|
+
return hit && hit !== AtlasResolve.AMBIGUOUS ? hit : null;
|
|
1370
|
+
}
|
|
1371
|
+
return { nodes: nodes, edges: data.edges, resolve: resolve };
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/* Reading history: every document you open becomes a browser history entry
|
|
1375
|
+
(a hash on the page URL), so the browser's own back and forward walk the
|
|
1376
|
+
trail of articles you read, and a reloaded tab reopens where it was.
|
|
1377
|
+
Hash-only navigation works in every mode, including a chart on file://. */
|
|
1378
|
+
var applyingHash = false;
|
|
1379
|
+
function hashDoc() {
|
|
1380
|
+
return location.hash.indexOf("#d=") === 0 ? decodeURIComponent(location.hash.slice(3)) : null;
|
|
1381
|
+
}
|
|
1382
|
+
function pushHash(path) {
|
|
1383
|
+
if (applyingHash) return;
|
|
1384
|
+
var want = path ? "#d=" + encodeURIComponent(path) : "";
|
|
1385
|
+
if (location.hash === want || (!want && !location.hash)) return;
|
|
1386
|
+
location.hash = want;
|
|
1387
|
+
}
|
|
1388
|
+
function onHashChange() {
|
|
1389
|
+
if (!app) return;
|
|
1390
|
+
var p = hashDoc();
|
|
1391
|
+
applyingHash = true;
|
|
1392
|
+
if (p) {
|
|
1393
|
+
var hit = app.byPath(p);
|
|
1394
|
+
if (hit && app.pinnedPath() !== p) app.pin(hit);
|
|
1395
|
+
} else app.clearPin();
|
|
1396
|
+
applyingHash = false;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
/* Panel chrome: the rail's icon buttons own the sidebar panels (click to
|
|
1400
|
+
open, click again to close), the reader appears when a document opens,
|
|
1401
|
+
and both regions resize by their grips. Wired once; geometry survives
|
|
1402
|
+
refresh and remount. Everything starts closed: the constellation is the
|
|
1403
|
+
opening view. */
|
|
1404
|
+
function wireChrome() {
|
|
1405
|
+
var grid = document.getElementById("grid");
|
|
1406
|
+
if (!grid || grid._wired) return;
|
|
1407
|
+
grid._wired = true;
|
|
1408
|
+
grid.classList.add("sb-off");
|
|
1409
|
+
grid.classList.add("side-off");
|
|
1410
|
+
var panels = { wiki: "rail-wiki", overview: "rail-map", folders: "rail-folders" };
|
|
1411
|
+
var active = null;
|
|
1412
|
+
function setPanel(name) {
|
|
1413
|
+
active = name;
|
|
1414
|
+
Object.keys(panels).forEach(function (k) {
|
|
1415
|
+
var p = document.getElementById(k);
|
|
1416
|
+
if (p) p.style.display = k === name ? "" : "none";
|
|
1417
|
+
var b = document.getElementById(panels[k]);
|
|
1418
|
+
if (b) {
|
|
1419
|
+
b.classList.toggle("is-on", k === name);
|
|
1420
|
+
b.setAttribute("aria-pressed", String(k === name));
|
|
1421
|
+
}
|
|
1422
|
+
});
|
|
1423
|
+
grid.classList.toggle("sb-off", !name);
|
|
1424
|
+
window.dispatchEvent(new Event("resize"));
|
|
1425
|
+
}
|
|
1426
|
+
Object.keys(panels).forEach(function (k) {
|
|
1427
|
+
var b = document.getElementById(panels[k]);
|
|
1428
|
+
if (b) b.addEventListener("click", function () { setPanel(active === k ? null : k); });
|
|
1429
|
+
});
|
|
1430
|
+
setPanel(null);
|
|
1431
|
+
openPanel = setPanel;
|
|
1432
|
+
function grip(id, varName, fromLeft, min, max) {
|
|
1433
|
+
var g = document.getElementById(id);
|
|
1434
|
+
if (!g) return;
|
|
1435
|
+
g.addEventListener("mousedown", function (e) {
|
|
1436
|
+
e.preventDefault();
|
|
1437
|
+
g.classList.add("dragging");
|
|
1438
|
+
/* Live resize, one update per animation frame: the panels and their
|
|
1439
|
+
text adapt while you hold, and the expensive svg refit waits for
|
|
1440
|
+
release so the drag stays smooth on a busy machine. */
|
|
1441
|
+
var pendingW = null, rafId = null;
|
|
1442
|
+
function width(ev) {
|
|
1443
|
+
var w = fromLeft ? ev.clientX - 44 : window.innerWidth - ev.clientX;
|
|
1444
|
+
var mx = typeof max === "function" ? max() : max;
|
|
1445
|
+
return Math.max(min, Math.min(mx, w));
|
|
1446
|
+
}
|
|
1447
|
+
function apply() {
|
|
1448
|
+
rafId = null;
|
|
1449
|
+
if (pendingW != null) grid.style.setProperty(varName, pendingW + "px");
|
|
1450
|
+
}
|
|
1451
|
+
pendingW = width(e);
|
|
1452
|
+
apply();
|
|
1453
|
+
function move(ev) {
|
|
1454
|
+
pendingW = width(ev);
|
|
1455
|
+
if (rafId == null) rafId = requestAnimationFrame(apply);
|
|
1456
|
+
}
|
|
1457
|
+
function up() {
|
|
1458
|
+
g.classList.remove("dragging");
|
|
1459
|
+
if (rafId != null) cancelAnimationFrame(rafId);
|
|
1460
|
+
apply();
|
|
1461
|
+
window.removeEventListener("mousemove", move);
|
|
1462
|
+
window.removeEventListener("mouseup", up);
|
|
1463
|
+
window.dispatchEvent(new Event("resize"));
|
|
1464
|
+
}
|
|
1465
|
+
window.addEventListener("mousemove", move);
|
|
1466
|
+
window.addEventListener("mouseup", up);
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
grip("sbgrip", "--sb-w", true, 190, 560);
|
|
1470
|
+
/* The reader can take up to half the screen: reading and editing want room. */
|
|
1471
|
+
grip("sidegrip", "--side-w", false, 240, function () { return Math.round(window.innerWidth / 2); });
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/* Mount point: the chart and serve pages carry a global DATA and boot at once;
|
|
1475
|
+
the folder app has no DATA until a directory is picked, so it sets its own
|
|
1476
|
+
backend and calls AtlasApp.mount with the data the shared pipeline built.
|
|
1477
|
+
A second mount (switching folders in place) tears the first one down. */
|
|
1478
|
+
function modeLabel(mode) {
|
|
1479
|
+
return mode === "live" ? "live" : mode === "chart" ? "chart" : mode === "browse" ? "folder, read only" : "folder";
|
|
1480
|
+
}
|
|
1481
|
+
window.AtlasApp = {
|
|
1482
|
+
mount: function (data, be) {
|
|
1483
|
+
if (be) backend = be;
|
|
1484
|
+
else if (typeof AtlasBackend !== "undefined") backend = AtlasBackend;
|
|
1485
|
+
if (app) app.destroy();
|
|
1486
|
+
app = init(data, null);
|
|
1487
|
+
/* A hash carried into the mount reopens that document: a reloaded tab
|
|
1488
|
+
lands where it was, and a stale hash from another corpus is dropped. */
|
|
1489
|
+
var p = hashDoc();
|
|
1490
|
+
if (p) {
|
|
1491
|
+
var hit = app.byPath(p);
|
|
1492
|
+
applyingHash = true;
|
|
1493
|
+
if (hit) app.pin(hit);
|
|
1494
|
+
else {
|
|
1495
|
+
try { history.replaceState(null, "", location.pathname + location.search); } catch (e) {}
|
|
1496
|
+
}
|
|
1497
|
+
applyingHash = false;
|
|
1498
|
+
}
|
|
1499
|
+
return app;
|
|
1500
|
+
},
|
|
1501
|
+
};
|
|
1502
|
+
wireChrome();
|
|
1503
|
+
window.addEventListener("hashchange", onHashChange);
|
|
1504
|
+
if (typeof DATA !== "undefined") window.AtlasApp.mount(DATA);
|
|
1505
|
+
})();
|