@cavelang/cli 0.36.10 → 0.38.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.
@@ -5,7 +5,12 @@
5
5
  * routes over the read-only `/api/*` endpoints; claims render from
6
6
  * *structured* row data (never by re-parsing text — no second grammar),
7
7
  * and every entity name, claim key and row id links onward: entity 360,
8
- * topic browse, belief-history timeline, lineage, search.
8
+ * relation graphs, topic browse, belief-history timeline, lineage, search.
9
+ *
10
+ * Each claim row leads with its statement and confidence; contexts, tags,
11
+ * comments, dates and the history/lineage links sit one click away in a
12
+ * native disclosure. Graphs are inline SVG laid out in the page by a small
13
+ * deterministic force simulation — still no library and no external fetch.
9
14
  *
10
15
  * `__CAVE_DB__`, `__CAVE_VERSION__`, and `__CAVE_SENSITIVITY__` are stamped by the server
11
16
  * (HTML-escaped) when the page is requested.
@@ -22,13 +27,17 @@ export const page = `<!doctype html>
22
27
  :root {
23
28
  --bg: #ffffff; --fg: #1c1e21; --dim: #71767c; --line: #e5e7ea;
24
29
  --accent: #0b62b8; --pill: #f1f3f5; --bad: #b3261e; --warn: #8a6100;
25
- --bar: #cfe3f7; --mark: #fff2c4;
30
+ --bar: #cfe3f7; --mark: #fff2c4; --edge: #9aa1a8; --node: #c3c8cd;
31
+ --c0: #4f8fd9; --c1: #d9774f; --c2: #4fae7a; --c3: #b56fd0;
32
+ --c4: #c9a43a; --c5: #d05f87; --c6: #4fb3b8; --c7: #7f8ad6;
26
33
  }
27
34
  @media (prefers-color-scheme: dark) {
28
35
  :root {
29
36
  --bg: #111417; --fg: #e4e6e8; --dim: #969ca3; --line: #272c31;
30
37
  --accent: #6db3f2; --pill: #1c2126; --bad: #ff8078; --warn: #dfb54a;
31
- --bar: #1f3c5a; --mark: #4a3f14;
38
+ --bar: #1f3c5a; --mark: #4a3f14; --edge: #5d656d; --node: #4a525a;
39
+ --c0: #5d9de6; --c1: #e68a63; --c2: #5fc08b; --c3: #c483de;
40
+ --c4: #d8b54d; --c5: #e07299; --c6: #5ec4c9; --c7: #929ce6;
32
41
  }
33
42
  }
34
43
  * { box-sizing: border-box; }
@@ -38,60 +47,84 @@ body {
38
47
  }
39
48
  a { color: var(--accent); text-decoration: none; }
40
49
  a:hover { text-decoration: underline; }
41
- a:focus-visible, input:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
50
+ a:focus-visible, input:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
42
51
  a:focus-visible { text-decoration: underline; }
43
52
  header {
44
- position: sticky; top: 0; display: flex; gap: 1rem; align-items: center;
53
+ position: sticky; top: 0; z-index: 5; display: flex; gap: 1rem; align-items: center;
45
54
  padding: .55rem 1rem; background: var(--bg); border-bottom: 1px solid var(--line);
46
55
  }
47
56
  header .brand { font-weight: 700; color: var(--fg); }
57
+ header nav { display: flex; gap: .8rem; font-size: .9rem; }
48
58
  header input[type=search] {
49
59
  flex: 1; min-width: 0; max-width: 26rem; padding: .3rem .6rem; border: 1px solid var(--line);
50
60
  border-radius: .4rem; background: var(--pill); color: var(--fg); font: inherit;
51
61
  }
52
62
  header label { color: var(--dim); font-size: .85rem; display: flex; gap: .35rem; align-items: center; }
63
+ #trail { max-width: 64rem; margin: 0 auto; padding: .35rem 1rem 0; font-size: .8rem; color: var(--dim); }
64
+ #trail:empty { display: none; }
65
+ #trail a { margin-left: .6ch; }
53
66
  main { max-width: 64rem; margin: 0 auto; padding: 1rem 1rem 3rem; }
54
67
  footer {
55
68
  max-width: 64rem; margin: 0 auto; padding: .6rem 1rem 1.4rem;
56
69
  color: var(--dim); font-size: .8rem; border-top: 1px solid var(--line);
57
70
  }
58
- h1 { font-size: 1.25rem; margin: .8rem 0 .4rem; word-break: break-word; }
71
+ h1 { font-size: 1.35rem; margin: .6rem 0 .3rem; word-break: break-word; }
59
72
  h1 .kind { color: var(--dim); font-weight: 400; font-size: .85rem; }
60
- section { margin: 1.1rem 0; }
73
+ section { margin: 1.3rem 0; }
61
74
  section h2 { font-size: .8rem; text-transform: uppercase; letter-spacing: .06em; color: var(--dim); margin: 0 0 .35rem; }
62
- section h2 .n { font-weight: 400; }
75
+ section h2 .n, summary .n { font-weight: 400; color: var(--dim); }
76
+ .lede { color: var(--dim); margin: .1rem 0 .6rem; }
63
77
  .none { color: var(--dim); font-size: .85rem; margin: .2rem 0; }
64
78
  .err { color: var(--bad); }
65
- .retry { padding: .35rem .7rem; border: 1px solid var(--line); border-radius: .4rem; background: var(--pill); color: var(--fg); font: inherit; cursor: pointer; }
66
- .tiles { display: flex; flex-wrap: wrap; gap: .5rem; }
79
+ button, .btn {
80
+ padding: .2rem .6rem; border: 1px solid var(--line); border-radius: .4rem; background: var(--pill);
81
+ color: var(--fg); font: inherit; font-size: .8rem; cursor: pointer;
82
+ }
83
+ button[aria-pressed=true] { border-color: var(--accent); color: var(--accent); }
84
+ .retry { padding: .35rem .7rem; font-size: inherit; }
85
+ .tiles { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .5rem; }
67
86
  .tile { background: var(--pill); border-radius: .5rem; padding: .45rem .8rem; min-width: 5.4rem; }
68
87
  .tile b { display: block; font-size: 1.15rem; }
69
88
  .tile span { color: var(--dim); font-size: .75rem; }
70
89
  .chips { display: flex; flex-wrap: wrap; gap: .4rem; }
71
90
  .chip { background: var(--pill); border-radius: 1rem; padding: .15rem .7rem; font-size: .85rem; }
72
91
  .chip .n { color: var(--dim); }
92
+ .chip.type { border: 1px solid var(--line); background: transparent; }
93
+ .fold { margin: .5rem 0; border-top: 1px solid var(--line); padding-top: .4rem; }
94
+ .fold > summary { cursor: pointer; font-size: .8rem; text-transform: uppercase; letter-spacing: .06em; color: var(--dim); }
95
+ .fold > summary b { color: var(--fg); font-weight: 600; }
96
+ .fold[open] > summary { margin-bottom: .35rem; }
73
97
  .claim {
74
- display: flex; flex-wrap: wrap; gap: .45ch; align-items: baseline;
75
98
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
76
- font-size: .84rem; padding: .18rem 0; border-bottom: 1px dotted var(--line);
77
- word-break: break-word;
99
+ font-size: .84rem; border-bottom: 1px dotted var(--line); word-break: break-word;
78
100
  }
79
101
  .claim:last-child { border-bottom: 0; }
80
- .claim.dim > :not(.meta) { opacity: .55; }
102
+ .claim > summary { list-style: none; cursor: pointer; padding: .2rem 0; }
103
+ .claim > summary::-webkit-details-marker { display: none; }
104
+ .claim .line { display: flex; flex-wrap: wrap; gap: .45ch; align-items: baseline; }
105
+ .claim .more { margin-left: auto; color: var(--dim); font-size: .74rem; padding: 0 .3rem; border-radius: .3rem; }
106
+ .claim > summary:hover .more { background: var(--pill); color: var(--accent); }
107
+ .claim[open] .more { color: var(--accent); }
108
+ .claim.dim .line > :not(.more) { opacity: .55; }
109
+ .claim .meta {
110
+ display: flex; flex-wrap: wrap; gap: .3rem .9ch; align-items: baseline;
111
+ margin: 0 0 .4rem 1.2rem; padding: .3rem .6rem; border-left: 2px solid var(--line); font-size: .78rem;
112
+ }
113
+ .claim .meta a, .claim .meta time { color: var(--dim); }
114
+ .claim .meta a:hover { color: var(--accent); }
115
+ .claim .meta .key { flex-basis: 100%; color: var(--dim); font-size: .72rem; }
81
116
  .v { font-weight: 600; }
82
117
  .not { color: var(--bad); font-style: normal; font-weight: 700; }
83
118
  .a { color: var(--dim); }
84
- .lit, .val { }
119
+ .self { color: var(--dim); }
85
120
  .ctx, .tag { background: var(--pill); border-radius: .3rem; padding: 0 .35rem; font-size: .78rem; color: var(--dim); }
86
121
  .ctx.src { color: var(--accent); }
87
122
  .conf { color: var(--warn); }
88
123
  .conf.retracted { color: var(--bad); }
89
124
  .imp { color: var(--bad); font-weight: 700; }
90
125
  .cmt { color: var(--dim); }
126
+ .hint { color: var(--dim); font-size: .74rem; }
91
127
  .rel { color: var(--dim); font-size: .78rem; border: 1px solid var(--line); border-radius: .3rem; padding: 0 .3rem; }
92
- .meta { margin-left: auto; white-space: nowrap; font-size: .74rem; }
93
- .meta a, .meta time { color: var(--dim); margin-left: .6ch; }
94
- .meta a:hover { color: var(--accent); }
95
128
  .bar { display: inline-block; width: 7rem; height: .5rem; background: var(--pill); border-radius: .25rem; overflow: hidden; flex: none; }
96
129
  .bar i { display: block; height: 100%; background: var(--bar); }
97
130
  .when { color: var(--dim); font-size: .74rem; width: 6.2rem; flex: none; }
@@ -99,21 +132,69 @@ section h2 .n { font-weight: 400; }
99
132
  .tree > li, .tree ul > li { margin: .15rem 0; }
100
133
  .role { color: var(--dim); font-size: .74rem; text-transform: uppercase; }
101
134
  .rep { color: var(--dim); font-size: .78rem; }
102
- .aka { color: var(--dim); font-size: .85rem; }
135
+ .aka { color: var(--dim); font-size: .85rem; margin: .2rem 0; }
136
+ .neighbors { display: grid; grid-template-columns: max-content 1fr; gap: .3rem .8rem; align-items: baseline; }
137
+ .neighbors .vb { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .78rem; color: var(--dim); text-align: right; }
103
138
  mark { background: var(--mark); color: inherit; border-radius: .2rem; }
104
139
  .linked { font-size: .8rem; color: var(--dim); margin-top: .3rem; }
140
+ .panel { border: 1px solid var(--line); border-radius: .6rem; overflow: hidden; position: relative; }
141
+ .panel .tools {
142
+ display: flex; flex-wrap: wrap; gap: .35rem; align-items: center; padding: .35rem .5rem;
143
+ border-bottom: 1px solid var(--line); font-size: .8rem; color: var(--dim);
144
+ }
145
+ .panel .tools .grow { flex: 1; }
146
+ .graph { display: block; width: 100%; height: 22rem; touch-action: none; user-select: none; cursor: grab; }
147
+ .graph.tall { height: min(70vh, 40rem); }
148
+ .graph .ed path { fill: none; stroke: var(--edge); stroke-width: 1.3; }
149
+ .graph .ed.neg path { stroke: var(--bad); stroke-dasharray: 4 3; }
150
+ .graph .ed text, .graph .n text {
151
+ font-size: 11px; fill: var(--dim); paint-order: stroke; stroke: var(--bg); stroke-width: 3px; stroke-linejoin: round;
152
+ }
153
+ .graph .ed.neg text { fill: var(--bad); }
154
+ .graph .n text { fill: var(--fg); font-size: 12px; }
155
+ .graph .n circle { fill: var(--node); stroke: var(--bg); stroke-width: 2; }
156
+ .graph .n.focus circle { stroke: var(--accent); stroke-width: 3; }
157
+ .graph .n.focus text { font-weight: 700; }
158
+ .graph .n { cursor: pointer; }
159
+ .graph .n:focus-visible circle { stroke: var(--accent); stroke-width: 3; }
160
+ .graph .n:focus { outline: none; }
161
+ .graph.dense .ed text { display: none; }
162
+ .graph.dense .ed.on text { display: inline; }
163
+ .graph.hl .n:not(.on), .graph.hl .ed:not(.on) { opacity: .15; }
164
+ .graph .ed.on path { stroke: var(--accent); stroke-width: 2; }
165
+ .graph .arrow { fill: var(--edge); }
166
+ .graph .c0 circle { fill: var(--c0); } .graph .c1 circle { fill: var(--c1); }
167
+ .graph .c2 circle { fill: var(--c2); } .graph .c3 circle { fill: var(--c3); }
168
+ .graph .c4 circle { fill: var(--c4); } .graph .c5 circle { fill: var(--c5); }
169
+ .graph .c6 circle { fill: var(--c6); } .graph .c7 circle { fill: var(--c7); }
170
+ .legend { display: flex; flex-wrap: wrap; gap: .3rem .9rem; padding: .35rem .6rem; font-size: .78rem; color: var(--dim); border-top: 1px solid var(--line); }
171
+ .legend:empty { display: none; }
172
+ .legend i { display: inline-block; width: .65rem; height: .65rem; border-radius: 50%; margin-right: .3rem; vertical-align: -.05rem; background: var(--node); }
173
+ .legend .c0 { background: var(--c0); } .legend .c1 { background: var(--c1); } .legend .c2 { background: var(--c2); }
174
+ .legend .c3 { background: var(--c3); } .legend .c4 { background: var(--c4); } .legend .c5 { background: var(--c5); }
175
+ .legend .c6 { background: var(--c6); } .legend .c7 { background: var(--c7); }
176
+ kbd { font: .75rem ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; border: 1px solid var(--line); border-radius: .25rem; padding: 0 .3rem; }
105
177
  .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
106
178
  .skip-link { position: fixed; top: .5rem; left: .5rem; z-index: 10; padding: .6rem .9rem; background: var(--bg); border: 2px solid var(--accent); border-radius: .3rem; transform: translateY(-200%); }
107
179
  .skip-link:focus { transform: translateY(0); }
180
+ @media (max-width: 40rem) {
181
+ header { gap: .6rem; flex-wrap: wrap; }
182
+ header input[type=search] { order: 5; flex-basis: 100%; max-width: none; }
183
+ .graph { height: 18rem; }
184
+ .neighbors { grid-template-columns: 1fr; }
185
+ .neighbors .vb { text-align: left; }
186
+ }
108
187
  </style>
109
188
  </head>
110
189
  <body>
111
190
  <a class="skip-link" href="#view">Skip to content</a>
112
191
  <header>
113
192
  <a class="brand" href="#/">cave</a>
114
- <input id="q" type="search" aria-label="Search claims by phrase" aria-keyshortcuts="Enter" enterkeyhint="search" placeholder="search phrase… (enter)">
193
+ <nav aria-label="Views"><a href="#/">overview</a><a href="#/g">graph</a></nav>
194
+ <input id="q" type="search" aria-label="Search claims by phrase" aria-keyshortcuts="Enter /" enterkeyhint="search" placeholder="search phrase… (press / )">
115
195
  <label><input id="aliases" type="checkbox"> aliases</label>
116
196
  </header>
197
+ <nav id="trail" aria-label="Recently visited entities"></nav>
117
198
  <div id="loading-status" class="sr-only" role="status"></div>
118
199
  <main id="view" tabindex="-1" aria-busy="false"></main>
119
200
  <footer>cave __CAVE_VERSION__ &middot; __CAVE_DB__ &middot; sensitivity &le; __CAVE_SENSITIVITY__ &middot; read-only view</footer>
@@ -187,21 +268,29 @@ function api (path, params) {
187
268
  }
188
269
 
189
270
  function isLiteral (name) { return name.charAt(0) === '"' || name.charAt(0) === BACKTICK }
190
- function entityHtml (name) {
191
- return isLiteral(name) ?
192
- '<span class="lit">' + esc(name) + '</span>' :
193
- '<a class="e" href="#/e/' + enc(name) + '">' + esc(name) + '</a>'
271
+ function entityHtml (name, self) {
272
+ if (isLiteral(name)) return '<span class="lit">' + esc(name) + '</span>'
273
+ if (self !== undefined && name === self) return '<span class="self">' + esc(name) + '</span>'
274
+ return '<a class="e" href="#/e/' + enc(name) + '">' + esc(name) + '</a>'
194
275
  }
195
276
 
277
+ /*
278
+ * One claim: the statement and its confidence lead; provenance, tags, the
279
+ * comment, the date and the history/lineage links open from the row.
280
+ * opts.when/bar add a timeline date and confidence bar, opts.self dims the
281
+ * page's own entity, opts.rel and opts.note prefix an annotation.
282
+ */
196
283
  function claimHtml (c, opts) {
197
284
  opts = opts || {}
198
285
  var parts = []
286
+ if (opts.rel !== undefined) { parts.push('<span class="rel" title="declared inverse — same stored fact read from this side">' + esc(opts.rel) + '</span>') }
199
287
  if (opts.when) { parts.push('<span class="when" title="' + esc(c.at) + '">' + esc(c.at.slice(0, 10)) + '</span>') }
200
288
  if (opts.bar) { parts.push('<span class="bar" title="confidence ' + esc(pct(c.conf)) + '"><i style="width:' + (c.conf * 100) + '%"></i></span>') }
201
- parts.push(entityHtml(c.subject))
289
+ if (opts.note !== undefined) { parts.push('<span class="hint">' + esc(opts.note) + '</span>') }
290
+ parts.push(entityHtml(c.subject, opts.self))
202
291
  parts.push('<span class="v">' + esc(c.verb) + '</span>' + (c.negated ? ' <em class="not">NOT</em>' : ''))
203
292
  if (c.object !== undefined) {
204
- parts.push(entityHtml(c.object))
293
+ parts.push(entityHtml(c.object, opts.self))
205
294
  } else if (c.attribute !== undefined) {
206
295
  parts.push('<span class="a">' + esc(c.attribute) + ':</span> <span class="val">' + esc(c.value) + '</span>')
207
296
  } else if (c.value !== undefined) {
@@ -209,30 +298,34 @@ function claimHtml (c, opts) {
209
298
  }
210
299
  if (c.delta !== undefined) { parts.push('<span class="val">+/- ' + esc(c.delta) + '</span>') }
211
300
  if (c.sigmaLevel !== undefined) { parts.push('<span class="val">(' + esc(c.sigmaLevel) + 'σ)</span>') }
301
+ if (c.conf === 0) {
302
+ parts.push('<span class="conf retracted" title="retracted">@ 0%</span>')
303
+ } else if (c.conf < 1) {
304
+ parts.push('<span class="conf">@ ' + esc(pct(c.conf)) + '</span>')
305
+ }
306
+ if (c.importance) { parts.push('<span class="imp" title="important">!</span>') }
307
+ var meta = []
308
+ if (c.comment !== undefined) { meta.push('<span class="cmt">; ' + esc(c.comment) + '</span>') }
212
309
  c.contexts.forEach(function (ctx) {
213
310
  var source = (c.sources || []).filter(function (item) { return item.context === ctx })[0]
214
311
  var label = '@' + esc(ctx)
215
312
  if (source && source.href) {
216
313
  label = '<a href="' + esc(source.href) + '" target="_blank" rel="noopener noreferrer" title="' + esc(source.location) + '">' + label + '</a>'
217
314
  }
218
- parts.push('<span class="ctx' + (ctx.indexOf('src:') === 0 ? ' src' : '') + '">' + label + '</span>')
315
+ meta.push('<span class="ctx' + (ctx.indexOf('src:') === 0 ? ' src' : '') + '">' + label + '</span>')
219
316
  })
220
317
  c.tags.forEach(function (tag) {
221
- parts.push('<span class="tag">#' + esc(tag.key) + (tag.value !== undefined ? ':' + esc(tag.value) : '') + '</span>')
318
+ meta.push('<span class="tag">#' + esc(tag.key) + (tag.value !== undefined ? ':' + esc(tag.value) : '') + '</span>')
222
319
  })
223
- if (c.conf === 0) {
224
- parts.push('<span class="conf retracted" title="retracted">@ 0%</span>')
225
- } else if (c.conf < 1) {
226
- parts.push('<span class="conf">@ ' + esc(pct(c.conf)) + '</span>')
320
+ meta.push('<time title="' + esc(c.at) + '">' + esc(c.at.slice(0, 19).replace('T', ' ')) + '</time>')
321
+ meta.push('<a href="#/k/' + enc(c.key) + '" title="belief history of this fact">history</a>')
322
+ if (c.cites > 0 || c.citedBy > 0) {
323
+ meta.push('<a href="#/l/' + enc(c.id) + '" title="cites ' + c.cites + ', cited by ' + c.citedBy + '">lineage</a>')
227
324
  }
228
- if (c.importance) { parts.push('<span class="imp">!</span>') }
229
- if (c.comment !== undefined) { parts.push('<span class="cmt">; ' + esc(c.comment) + '</span>') }
230
- var meta = '<span class="meta">' +
231
- '<a href="#/k/' + enc(c.key) + '" title="belief history of this fact">history</a>' +
232
- (c.cites > 0 || c.citedBy > 0 ? '<a href="#/l/' + enc(c.id) + '" title="cites ' + c.cites + ', cited by ' + c.citedBy + '">lineage</a>' : '') +
233
- (opts.when ? '' : '<time title="' + esc(c.at) + '">' + esc(c.at.slice(0, 10)) + '</time>') +
234
- '</span>'
235
- return '<div class="claim' + (c.conf === 0 ? ' dim' : '') + '">' + parts.join(' ') + meta + '</div>'
325
+ var hints = (c.comment !== undefined ? 'note ' : '') + (c.cites > 0 ? 'why ' : '') + '…'
326
+ return '<details class="claim' + (c.conf === 0 ? ' dim' : '') + '"><summary><span class="line">' + parts.join(' ') +
327
+ '<span class="more" aria-hidden="true" title="details">' + esc(hints) + '</span></span></summary>' +
328
+ '<div class="meta">' + meta.join(' ') + '</div></details>'
236
329
  }
237
330
 
238
331
  function claimList (list, opts) {
@@ -241,6 +334,10 @@ function claimList (list, opts) {
241
334
  function section (title, body, count) {
242
335
  return '<section><h2>' + title + (count !== undefined ? ' <span class="n">(' + count + ')</span>' : '') + '</h2>' + body + '</section>'
243
336
  }
337
+ function fold (title, body, count, open) {
338
+ return '<details class="fold"' + (open ? ' open' : '') + '><summary><b>' + title + '</b>' +
339
+ (count !== undefined ? ' <span class="n">(' + count + ')</span>' : '') + '</summary>' + body + '</details>'
340
+ }
244
341
  function capNote (capped) {
245
342
  return capped.items.length < capped.total ?
246
343
  '<p class="none">…and ' + (capped.total - capped.items.length) + ' more (see cave check)</p>' : ''
@@ -248,6 +345,7 @@ function capNote (capped) {
248
345
  function tile (value, label) {
249
346
  return '<div class="tile"><b>' + value + '</b><span>' + label + '</span></div>'
250
347
  }
348
+ function plural (n, word, many) { return n + ' ' + (n === 1 ? word : many || word + 's') }
251
349
 
252
350
  function shapeProblem (violation) {
253
351
  if (violation.actualCount === 0) return 'missing ' + violation.kind + ' ' + violation.name
@@ -262,77 +360,508 @@ function shapeProblem (violation) {
262
360
  return problems.join('; ')
263
361
  }
264
362
 
363
+ /*
364
+ * Relation graphs — inline SVG, laid out here; nodes are links onward.
365
+ * Dictionaries keyed by stored names are prototype-free: an entity may be
366
+ * called __proto__ or constructor.
367
+ */
368
+
369
+ function hashOf (text) {
370
+ var hash = 0
371
+ for (var i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) | 0
372
+ return Math.abs(hash)
373
+ }
374
+ /* The eight most common first types in this graph get distinct colours. */
375
+ function typeColors (nodes) {
376
+ var counts = Object.create(null)
377
+ nodes.forEach(function (node) { if (node.types.length) counts[node.types[0]] = (counts[node.types[0]] || 0) + 1 })
378
+ var colors = Object.create(null)
379
+ Object.keys(counts).sort(function (a, b) { return counts[b] - counts[a] || (a < b ? -1 : 1) }).slice(0, 8).forEach(function (type, i) {
380
+ colors[type] = ' c' + i
381
+ })
382
+ return colors
383
+ }
384
+
385
+ function layoutGraph (data, width, height) {
386
+ var nodes = data.nodes
387
+ var n = nodes.length
388
+ var index = Object.create(null)
389
+ nodes.forEach(function (node, i) { index[node.name] = i })
390
+ // One spring per linked pair, however many relations it carries.
391
+ var seen = Object.create(null)
392
+ var links = []
393
+ var degree = nodes.map(function () { return 0 })
394
+ data.edges.forEach(function (edge) {
395
+ var s = index[edge.source], t = index[edge.target]
396
+ if (s === undefined || t === undefined || s === t) return
397
+ var id = Math.min(s, t) + ':' + Math.max(s, t)
398
+ if (seen[id]) return
399
+ seen[id] = true
400
+ links.push([s, t])
401
+ degree[s]++; degree[t]++
402
+ })
403
+ var cx = width / 2
404
+ var cy = height / 2
405
+ // Deterministic start: the focus centred, the rest on a golden-angle spiral.
406
+ var pos = nodes.map(function (node, i) {
407
+ if (node.name === data.focus) return { x: cx, y: cy }
408
+ var r = 24 * Math.sqrt(i + 1)
409
+ var angle = i * 2.39996 + (hashOf(node.name) % 100) / 100
410
+ return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) }
411
+ })
412
+ // Velocity simulation: pairwise charge, springs on relations, mild gravity.
413
+ var span = Math.min(90, Math.max(36, Math.sqrt(width * height / Math.max(n, 1)) * 0.55))
414
+ var charge = span * span * 1.2
415
+ var vel = pos.map(function () { return { x: 0, y: 0 } })
416
+ var alpha = 1
417
+ for (var step = 0; step < 320; step++) {
418
+ for (var a = 0; a < n; a++) {
419
+ for (var b = a + 1; b < n; b++) {
420
+ var dx = pos[b].x - pos[a].x
421
+ var dy = pos[b].y - pos[a].y
422
+ var d2 = Math.max(dx * dx + dy * dy, 16)
423
+ var push = charge * alpha / d2
424
+ vel[a].x -= dx * push / Math.sqrt(d2); vel[a].y -= dy * push / Math.sqrt(d2)
425
+ vel[b].x += dx * push / Math.sqrt(d2); vel[b].y += dy * push / Math.sqrt(d2)
426
+ }
427
+ }
428
+ // Springs weaken with degree and move the less connected end more (as in
429
+ // d3-force), so a hub's many springs cannot add up and fling it.
430
+ links.forEach(function (link) {
431
+ var s = pos[link[0]], t = pos[link[1]]
432
+ var dx = t.x - s.x, dy = t.y - s.y
433
+ var d = Math.sqrt(dx * dx + dy * dy) || 1
434
+ var pull = (d - span) / d * alpha * 0.5 / Math.min(degree[link[0]], degree[link[1]])
435
+ var bias = degree[link[0]] / (degree[link[0]] + degree[link[1]])
436
+ vel[link[0]].x += dx * pull * (1 - bias); vel[link[0]].y += dy * pull * (1 - bias)
437
+ vel[link[1]].x -= dx * pull * bias; vel[link[1]].y -= dy * pull * bias
438
+ })
439
+ for (var i = 0; i < n; i++) {
440
+ // Gravity keeps disconnected clusters close enough to share the frame.
441
+ vel[i].x += (cx - pos[i].x) * 0.02 * alpha
442
+ vel[i].y += (cy - pos[i].y) * 0.02 * alpha * width / height
443
+ if (nodes[i].name === data.focus) { vel[i].x = 0; vel[i].y = 0; continue }
444
+ // Bound each step; a non-finite velocity is discarded rather than spread.
445
+ var speed = Math.sqrt(vel[i].x * vel[i].x + vel[i].y * vel[i].y)
446
+ if (!(speed < Infinity)) { vel[i].x = 0; vel[i].y = 0; speed = 0 }
447
+ if (speed > span) { vel[i].x *= span / speed; vel[i].y *= span / speed }
448
+ pos[i].x += vel[i].x; pos[i].y += vel[i].y
449
+ vel[i].x *= 0.6; vel[i].y *= 0.6
450
+ }
451
+ alpha *= 0.985
452
+ }
453
+ // Fit the result into the viewport, leaving room for labels.
454
+ var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
455
+ pos.forEach(function (p) {
456
+ minX = Math.min(minX, p.x); maxX = Math.max(maxX, p.x)
457
+ minY = Math.min(minY, p.y); maxY = Math.max(maxY, p.y)
458
+ })
459
+ var padX = 70, padY = 30
460
+ // Each axis fills the frame, within a bounded stretch so shapes stay readable.
461
+ var scaleX = Math.min((width - 2 * padX) / Math.max(maxX - minX, 1), 2.5)
462
+ var scaleY = Math.min((height - 2 * padY) / Math.max(maxY - minY, 1), 2.5)
463
+ scaleX = Math.min(scaleX, scaleY * 3)
464
+ scaleY = Math.min(scaleY, scaleX * 3)
465
+ var offsetX = (width - (maxX - minX) * scaleX) / 2
466
+ var offsetY = (height - (maxY - minY) * scaleY) / 2
467
+ return pos.map(function (p) { return { x: offsetX + (p.x - minX) * scaleX, y: offsetY + (p.y - minY) * scaleY } })
468
+ }
469
+
470
+ function radiusOf (node, focus) {
471
+ return node.name === focus ? 11 : Math.min(5 + Math.sqrt(node.degree) * 1.8, 13)
472
+ }
473
+
474
+ function drawGraph (target, data, opts) {
475
+ opts = opts || {}
476
+ if (data.nodes.length === 0) {
477
+ target.innerHTML = '<p class="none" style="padding:.6rem">no relations between entities to draw</p>'
478
+ return
479
+ }
480
+ var width = Math.max(target.clientWidth || 0, 320)
481
+ var height = opts.tall ? Math.max(Math.min(window.innerHeight * 0.7, 640), 320) : (width < 640 ? 288 : 352)
482
+ var pos = layoutGraph(data, width, height)
483
+ var colors = typeColors(data.nodes)
484
+ function colorOf (node) { return node.types.length ? colors[node.types[0]] || '' : '' }
485
+ var index = Object.create(null)
486
+ data.nodes.forEach(function (node, i) { index[node.name] = i })
487
+ // One path per ordered pair; verbs on the same pair share a label.
488
+ var pairs = Object.create(null)
489
+ var order = []
490
+ data.edges.forEach(function (edge) {
491
+ if (edge.source === edge.target) return
492
+ var id = edge.source + '\\u0000' + edge.target
493
+ if (!pairs[id]) { pairs[id] = { source: edge.source, target: edge.target, edges: [] }; order.push(id) }
494
+ pairs[id].edges.push(edge)
495
+ })
496
+ var dense = order.length > 40
497
+ var svg = '<svg class="graph' + (opts.tall ? ' tall' : '') + (dense ? ' dense' : '') + '" viewBox="0 0 ' + width + ' ' + height +
498
+ '" role="group" aria-label="' + esc(opts.label || 'relation graph') + '" style="height:' + height + 'px">' +
499
+ '<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">' +
500
+ '<path class="arrow" d="M0,0L10,5L0,10z"/></marker></defs><g class="vp">'
501
+ order.forEach(function (id, i) {
502
+ var pair = pairs[id]
503
+ var negated = pair.edges.every(function (edge) { return edge.negated })
504
+ var label = pair.edges.map(function (edge) {
505
+ return (edge.negated ? 'NOT ' : '') + edge.verb + (edge.conf < 1 ? ' ' + pct(edge.conf) : '')
506
+ }).join(', ')
507
+ var opacity = Math.max(0.35, Math.max.apply(null, pair.edges.map(function (edge) { return edge.conf })))
508
+ svg += '<g class="ed' + (negated ? ' neg' : '') + '" data-i="' + i + '" data-s="' + index[pair.source] + '" data-t="' + index[pair.target] +
509
+ '" data-back="' + (pairs[pair.target + '\\u0000' + pair.source] ? 1 : 0) + '" style="opacity:' + opacity + '">' +
510
+ '<title>' + esc(pair.source + ' ' + label + ' ' + pair.target) + '</title>' +
511
+ '<path marker-end="url(#arrow)"/><text text-anchor="middle">' + esc(label) + '</text></g>'
512
+ })
513
+ data.nodes.forEach(function (node, i) {
514
+ var r = radiusOf(node, data.focus)
515
+ var title = node.name + (node.types.length ? ' — IS ' + node.types.join(', ') : '') + ' — ' + plural(node.degree, 'relation')
516
+ svg += '<a class="n' + colorOf(node) + (node.name === data.focus ? ' focus' : '') + '" href="#/e/' + esc(enc(node.name)) +
517
+ '" data-i="' + i + '" data-r="' + r + '"><title>' + esc(title) + '</title><circle r="' + r + '"/>' +
518
+ '<text dy="' + (r + 13) + '" text-anchor="middle">' + esc(node.name.length > 28 ? node.name.slice(0, 27) + '…' : node.name) + '</text></a>'
519
+ })
520
+ svg += '</g></svg>'
521
+ var legendHtml = Object.keys(colors).map(function (type) {
522
+ return '<span><i class="' + colors[type].trim() + '"></i>' + esc(type) + '</span>'
523
+ }).join('')
524
+ target.innerHTML = svg + '<div class="legend">' + legendHtml + '</div>'
525
+ wireGraph(target.querySelector('svg'), data, pos)
526
+ }
527
+
528
+ function wireGraph (svg, data, pos) {
529
+ var nodeEls = Array.prototype.slice.call(svg.querySelectorAll('.n'))
530
+ var edgeEls = Array.prototype.slice.call(svg.querySelectorAll('.ed'))
531
+ var view0 = svg.getAttribute('viewBox').split(' ').map(Number)
532
+ var box = view0.slice()
533
+ function placeEdge (el) {
534
+ var s = pos[+el.getAttribute('data-s')]
535
+ var t = pos[+el.getAttribute('data-t')]
536
+ var rs = +nodeEls[+el.getAttribute('data-s')].getAttribute('data-r')
537
+ var rt = +nodeEls[+el.getAttribute('data-t')].getAttribute('data-r')
538
+ var dx = t.x - s.x, dy = t.y - s.y
539
+ var d = Math.sqrt(dx * dx + dy * dy) || 1
540
+ // Opposite directions on one pair bow apart instead of overlapping.
541
+ var bend = el.getAttribute('data-back') === '1' ? Math.min(28, d / 4) : 0
542
+ var mx = (s.x + t.x) / 2 - dy / d * bend
543
+ var my = (s.y + t.y) / 2 + dx / d * bend
544
+ var sx = s.x + (mx - s.x) / (Math.sqrt((mx - s.x) * (mx - s.x) + (my - s.y) * (my - s.y)) || 1) * rs
545
+ var sy = s.y + (my - s.y) / (Math.sqrt((mx - s.x) * (mx - s.x) + (my - s.y) * (my - s.y)) || 1) * rs
546
+ var tl = Math.sqrt((t.x - mx) * (t.x - mx) + (t.y - my) * (t.y - my)) || 1
547
+ var tx = t.x - (t.x - mx) / tl * (rt + 2)
548
+ var ty = t.y - (t.y - my) / tl * (rt + 2)
549
+ el.querySelector('path').setAttribute('d', 'M' + sx.toFixed(1) + ',' + sy.toFixed(1) + 'Q' + mx.toFixed(1) + ',' + my.toFixed(1) + ' ' + tx.toFixed(1) + ',' + ty.toFixed(1))
550
+ var text = el.querySelector('text')
551
+ text.setAttribute('x', ((s.x + t.x) / 2 - dy / d * bend / 2).toFixed(1))
552
+ text.setAttribute('y', ((s.y + t.y) / 2 + dx / d * bend / 2 - 3).toFixed(1))
553
+ }
554
+ function placeNode (el) {
555
+ var p = pos[+el.getAttribute('data-i')]
556
+ el.setAttribute('transform', 'translate(' + p.x.toFixed(1) + ',' + p.y.toFixed(1) + ')')
557
+ }
558
+ nodeEls.forEach(placeNode)
559
+ edgeEls.forEach(placeEdge)
560
+
561
+ function highlight (i) {
562
+ if (i === undefined) {
563
+ svg.classList.remove('hl')
564
+ nodeEls.concat(edgeEls).forEach(function (el) { el.classList.remove('on') })
565
+ return
566
+ }
567
+ svg.classList.add('hl')
568
+ nodeEls[i].classList.add('on')
569
+ edgeEls.forEach(function (el) {
570
+ var s = +el.getAttribute('data-s'), t = +el.getAttribute('data-t')
571
+ if (s === i || t === i) {
572
+ el.classList.add('on')
573
+ nodeEls[s === i ? t : s].classList.add('on')
574
+ }
575
+ })
576
+ }
577
+ nodeEls.forEach(function (el, i) {
578
+ el.addEventListener('mouseenter', function () { highlight(i) })
579
+ el.addEventListener('mouseleave', function () { highlight() })
580
+ el.addEventListener('focus', function () { highlight(i) })
581
+ el.addEventListener('blur', function () { highlight() })
582
+ })
583
+
584
+ function toGraph (event) {
585
+ var rect = svg.getBoundingClientRect()
586
+ return {
587
+ x: box[0] + (event.clientX - rect.left) / rect.width * box[2],
588
+ y: box[1] + (event.clientY - rect.top) / rect.height * box[3]
589
+ }
590
+ }
591
+ function applyBox () { svg.setAttribute('viewBox', box.map(function (v) { return v.toFixed(1) }).join(' ')) }
592
+ var drag
593
+ var handled = false
594
+ svg.addEventListener('pointerdown', function (event) {
595
+ // Modified clicks keep the browser's own link handling (new tab, window).
596
+ if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return
597
+ var node = event.target.closest ? event.target.closest('.n') : null
598
+ var start = toGraph(event)
599
+ drag = { node: node, start: start, client: { x: event.clientX, y: event.clientY }, box: box.slice(), moved: false }
600
+ svg.setPointerCapture(event.pointerId)
601
+ })
602
+ svg.addEventListener('pointermove', function (event) {
603
+ if (!drag) return
604
+ if (Math.abs(event.clientX - drag.client.x) + Math.abs(event.clientY - drag.client.y) > 4) drag.moved = true
605
+ if (!drag.moved) return
606
+ if (drag.node) {
607
+ var point = toGraph(event)
608
+ var i = +drag.node.getAttribute('data-i')
609
+ pos[i].x = point.x; pos[i].y = point.y
610
+ placeNode(drag.node)
611
+ edgeEls.forEach(function (el) {
612
+ if (+el.getAttribute('data-s') === i || +el.getAttribute('data-t') === i) placeEdge(el)
613
+ })
614
+ } else {
615
+ var rect = svg.getBoundingClientRect()
616
+ box[0] = drag.box[0] - (event.clientX - drag.client.x) / rect.width * box[2]
617
+ box[1] = drag.box[1] - (event.clientY - drag.client.y) / rect.height * box[3]
618
+ applyBox()
619
+ }
620
+ })
621
+ function endDrag (event) {
622
+ if (!drag) return
623
+ var moved = drag.moved
624
+ var node = drag.node
625
+ drag = undefined
626
+ if (svg.hasPointerCapture(event.pointerId)) svg.releasePointerCapture(event.pointerId)
627
+ handled = moved || !!node
628
+ // A pointer capture retargets the click; follow the node link explicitly.
629
+ if (!moved && node && event.type === 'pointerup') location.hash = node.getAttribute('href')
630
+ }
631
+ svg.addEventListener('pointerup', endDrag)
632
+ svg.addEventListener('pointercancel', endDrag)
633
+ // Pointer navigation happened on pointerup; keyboard Enter still clicks the link.
634
+ svg.addEventListener('click', function (event) {
635
+ if (handled) event.preventDefault()
636
+ handled = false
637
+ })
638
+ var panel = svg.parentNode.parentNode
639
+ function zoom (factor) {
640
+ var cx = box[0] + box[2] / 2, cy = box[1] + box[3] / 2
641
+ box[2] = Math.min(Math.max(box[2] * factor, view0[2] / 8), view0[2] * 4)
642
+ box[3] = box[2] * view0[3] / view0[2]
643
+ box[0] = cx - box[2] / 2; box[1] = cy - box[3] / 2
644
+ applyBox()
645
+ }
646
+ Array.prototype.forEach.call(panel.querySelectorAll('[data-zoom]'), function (button) {
647
+ button.onclick = function () {
648
+ var action = button.getAttribute('data-zoom')
649
+ if (action === 'reset') { box = view0.slice(); applyBox() } else zoom(action === 'in' ? 0.8 : 1.25)
650
+ }
651
+ })
652
+ }
653
+
654
+ function graphPanel (id, tools) {
655
+ return '<div class="panel"><div class="tools">' + tools + '<span class="grow"></span>' +
656
+ '<button type="button" data-zoom="in" aria-label="Zoom in">+</button>' +
657
+ '<button type="button" data-zoom="out" aria-label="Zoom out">&minus;</button>' +
658
+ '<button type="button" data-zoom="reset">reset</button></div>' +
659
+ '<div id="' + id + '"><p class="none" style="padding:.6rem">drawing graph&hellip;</p></div></div>'
660
+ }
661
+ function depthButtons (depth) {
662
+ return '<span>hops</span>' + [1, 2, 3].map(function (d) {
663
+ return '<button type="button" data-depth="' + d + '" aria-pressed="' + (d === depth) + '">' + d + '</button>'
664
+ }).join('')
665
+ }
666
+ function graphCount (data) {
667
+ return plural(data.nodes.length, 'entity', 'entities') +
668
+ (data.total > data.nodes.length ? ' of ' + data.total + ' (most connected shown)' : '') +
669
+ ' &middot; ' + plural(data.edges.length, 'relation')
670
+ }
671
+ /* Fetches a neighborhood after its page renders; depth buttons redraw in place. */
672
+ function neighborhood (name, depth, opts) {
673
+ opts = opts || {}
674
+ var id = opts.id || 'egraph'
675
+ var target = view.querySelector('#' + id)
676
+ if (!target) return
677
+ var panel = target.parentNode
678
+ Array.prototype.forEach.call(panel.querySelectorAll('[data-depth]'), function (button) {
679
+ button.setAttribute('aria-pressed', String(+button.getAttribute('data-depth') === depth))
680
+ button.onclick = function () { neighborhood(name, +button.getAttribute('data-depth'), opts) }
681
+ })
682
+ var link = panel.querySelector('.open-graph')
683
+ if (link) link.setAttribute('href', '#/g/' + depth + '/' + enc(name))
684
+ later('#' + id, api('graph', { name: name, depth: depth, aliases: aliasBox.checked }), function (el, data) {
685
+ var count = panel.querySelector('.count')
686
+ if (count) count.innerHTML = graphCount(data)
687
+ drawGraph(el, data, { tall: opts.tall, label: 'relation graph around ' + name })
688
+ })
689
+ }
690
+
691
+ /*
692
+ * Loads a secondary panel for the current view. Superseded navigation drops
693
+ * it, and so does a newer request for the same panel (a hop change while the
694
+ * previous graph is still loading), whatever order the responses arrive in.
695
+ */
696
+ var panelRequests = Object.create(null)
697
+ function later (selector, promise, render) {
698
+ var current = navigation
699
+ var ticket = panelRequests[selector] = (panelRequests[selector] || 0) + 1
700
+ function live () { return current === navigation && panelRequests[selector] === ticket }
701
+ promise.then(function (data) {
702
+ var target = view.querySelector(selector)
703
+ if (live() && target) render(target, data)
704
+ }).catch(function (error) {
705
+ var target = view.querySelector(selector)
706
+ if (live() && target) target.innerHTML = '<p class="err" style="padding:.6rem">' + esc(error.message) + '</p>'
707
+ })
708
+ }
709
+
710
+ /* Recently visited entities — this page session only, never stored. */
711
+ var trail = []
712
+ function visit (name) {
713
+ trail = trail.filter(function (item) { return item !== name })
714
+ trail.push(name)
715
+ if (trail.length > 8) trail.shift()
716
+ var nav = document.getElementById('trail')
717
+ if (!nav) return
718
+ var earlier = trail.slice(0, -1).reverse()
719
+ nav.innerHTML = earlier.length === 0 ? '' : 'visited' + earlier.map(function (item) {
720
+ return '<a href="#/e/' + enc(item) + '">' + esc(item) + '</a>'
721
+ }).join('<span aria-hidden="true"> &middot;</span>')
722
+ }
723
+
265
724
  function dashboard (data) {
266
725
  var cov = data.coverage
267
- var html = ''
268
- html += section('coverage', '<div class="tiles">' +
726
+ var html = '<h1>Overview</h1>'
727
+ html += '<p class="lede">' + plural(cov.entities, 'entity', 'entities') + ' &middot; ' +
728
+ plural(cov.current, 'current belief') + ' &middot; ' + plural(data.topics.length, 'topic') +
729
+ (cov.averageConfidence === null ? '' : ' &middot; average confidence ' + esc(pct(cov.averageConfidence))) + '</p>'
730
+ html += fold('all coverage stats', '<div class="tiles">' +
269
731
  tile(cov.rows, 'rows') + tile(cov.facts, 'facts') + tile(cov.current, 'current') +
270
732
  tile(cov.entities, 'entities') + tile(cov.typedEntities, 'typed') +
271
733
  tile(cov.averageConfidence === null ? '&mdash;' : esc(pct(cov.averageConfidence)), 'avg conf') +
272
734
  tile(cov.retracted, 'retracted') + tile(cov.negated, 'negated') + tile(cov.lowConfidence, 'low conf') +
273
735
  tile(cov.satisfied + '/' + cov.checks, 'shape checks') +
274
736
  '</div>')
737
+ html += section('map', graphPanel('map', '<span class="count"></span>') +
738
+ '<p class="linked">The most connected entities and the current relations between them. Drag to rearrange, click an entity to open it, or <a href="#/g">open the full graph</a>.</p>')
275
739
  if (data.topics.length > 0) {
276
740
  html += section('topics', '<div class="chips">' + data.topics.map(function (topic) {
277
741
  return '<a class="chip" href="#/t/' + enc(topic.name) + '">' + esc(topic.name) + ' <span class="n">' + topic.members + '</span></a>'
278
742
  }).join('') + '</div>')
279
743
  }
744
+ var attention = ''
280
745
  if (data.violations.total > 0) {
281
- html += section('shape violations', data.violations.items.map(function (violation) {
282
- return '<div class="claim">' + entityHtml(violation.entity) +
283
- '<span class="cmt">' + esc(shapeProblem(violation)) + '</span>' +
284
- '<span class="cmt">; ' + esc(violation.entity) + ' IS ' + esc(violation.via) + ', ' + esc(violation.type) + ' EXPECTS ' + esc(violation.name) + '</span></div>'
746
+ attention += fold('shape violations', data.violations.items.map(function (violation) {
747
+ return '<p class="claim" style="padding:.2rem 0;margin:0">' + entityHtml(violation.entity) +
748
+ ' <span class="cmt">' + esc(shapeProblem(violation)) + '</span>' +
749
+ ' <span class="hint">; ' + esc(violation.entity) + ' IS ' + esc(violation.via) + ', ' + esc(violation.type) + ' EXPECTS ' + esc(violation.name) + '</span></p>'
285
750
  }).join('') + capNote(data.violations), data.violations.total)
286
751
  }
287
752
  if (data.review.total > 0) {
288
- html += section('review candidates <span class="n">conf 0.3&ndash;0.7</span>', claimList(data.review.items) + capNote(data.review), data.review.total)
753
+ attention += fold('review candidates <span class="n">conf 0.3&ndash;0.7</span>', claimList(data.review.items) + capNote(data.review), data.review.total)
289
754
  }
290
755
  if (data.disagreements.total > 0) {
291
- html += section('alias disagreements', data.disagreements.items.map(function (disagreement) {
292
- return '<p class="none">' + esc(disagreement.about) + ' across ' + disagreement.entities.map(entityHtml).join(', ') + '</p>' +
756
+ attention += fold('alias disagreements', data.disagreements.items.map(function (disagreement) {
757
+ return '<p class="none">' + esc(disagreement.about) + ' across ' + disagreement.entities.map(function (name) { return entityHtml(name) }).join(', ') + '</p>' +
293
758
  claimList(disagreement.rows)
294
759
  }).join('') + capNote(data.disagreements), data.disagreements.total)
295
760
  }
296
761
  if (data.stale.total > 0) {
297
- html += section('stale', data.stale.items.map(function (stale) {
298
- return claimHtml(stale.row).replace('<span class="meta">', '<span class="meta">' + stale.ageDays + 'd')
762
+ attention += fold('stale', data.stale.items.map(function (stale) {
763
+ return claimHtml(stale.row, { note: stale.ageDays + 'd' })
299
764
  }).join('') + capNote(data.stale), data.stale.total)
300
765
  }
301
- html += section('recent', claimList(data.recent, { when: true }))
766
+ if (attention !== '') html += section('needs attention', attention)
767
+ var recent = data.recent
768
+ html += section('recent', claimList(recent.slice(0, 10), { when: true }) +
769
+ (recent.length > 10 ? fold('older appends', claimList(recent.slice(10), { when: true }), recent.length - 10) : ''))
302
770
  view.innerHTML = html
771
+ later('#map', api('graph', {}), function (el, graph) {
772
+ var count = view.querySelector('.panel .count')
773
+ if (count) count.innerHTML = graphCount(graph)
774
+ drawGraph(el, graph, { label: 'map of the most connected entities' })
775
+ })
776
+ }
777
+
778
+ /* Neighbors grouped by relation, both directions — the quick way onward. */
779
+ function neighborsHtml (data) {
780
+ var groups = Object.create(null)
781
+ var order = []
782
+ function add (label, name) {
783
+ if (isLiteral(name) || name === data.name) return
784
+ if (!groups[label]) { groups[label] = []; order.push(label) }
785
+ if (groups[label].indexOf(name) < 0) groups[label].push(name)
786
+ }
787
+ data.out.forEach(function (c) { if (c.conf > 0) add((c.negated ? 'NOT ' : '') + c.verb + ' →', c.object) })
788
+ data.in.forEach(function (c) {
789
+ if (c.conf > 0) add(c.rel !== undefined ? (c.negated ? 'NOT ' : '') + c.rel + ' →' : '← ' + (c.negated ? 'NOT ' : '') + c.verb, c.subject)
790
+ })
791
+ if (order.length === 0) return ''
792
+ return section('related', '<div class="neighbors">' + order.map(function (label) {
793
+ return '<span class="vb">' + esc(label) + '</span><div class="chips">' + groups[label].map(function (name) {
794
+ return '<a class="chip" href="#/e/' + enc(name) + '">' + esc(name) + '</a>'
795
+ }).join('') + '</div>'
796
+ }).join('') + '</div>')
303
797
  }
304
798
 
305
799
  function entityPage (data) {
306
800
  var html = '<h1>' + esc(data.name) + ' <span class="kind">entity</span></h1>'
307
- if (data.types.length > 0) {
308
- html += '<p class="aka">IS ' + data.types.map(entityHtml).join(', ') + '</p>'
309
- }
801
+ var badges = data.types.map(function (name) {
802
+ return '<a class="chip type" href="#/e/' + enc(name) + '" title="' + esc(data.name) + ' IS ' + esc(name) + '">' + esc(name) + '</a>'
803
+ }).concat(data.topics.map(function (name) {
804
+ return '<a class="chip" href="#/t/' + enc(name) + '" title="topic">' + esc(name) + '</a>'
805
+ }))
806
+ if (badges.length > 0) html += '<div class="chips">' + badges.join('') + '</div>'
310
807
  if (data.aliases.length > 1) {
311
- html += '<p class="aka">aka ' + data.aliases.slice(1).map(entityHtml).join(', ') + '</p>'
808
+ html += '<p class="aka">aka ' + data.aliases.slice(1).map(function (name) { return entityHtml(name) }).join(', ') + '</p>'
312
809
  }
313
- if (data.topics.length > 0) {
314
- html += '<div class="chips">' + data.topics.map(function (name) {
315
- return '<a class="chip" href="#/t/' + enc(name) + '">' + esc(name) + '</a>'
316
- }).join('') + '</div>'
810
+ var relations = data.out.length + data.in.length
811
+ if (relations > 0) {
812
+ html += section('neighborhood', graphPanel('egraph', depthButtons(1) + ' <span class="count"></span> <a class="open-graph" href="#/g/1/' + enc(data.name) + '">full screen</a>'))
317
813
  }
318
- html += section('facts', claimList(data.facts))
319
- html += section('relations — as subject', claimList(data.out))
320
- html += section('relations — as object', data.in.length === 0 ? '<p class="none">none</p>' : data.in.map(function (c) {
321
- var rel = c.rel !== undefined ? '<span class="rel" title="declared inverse — same stored fact read from this side">' + esc(c.rel) + '</span> ' : ''
322
- return claimHtml(c).replace('<div class="claim">', '<div class="claim">' + rel)
323
- }).join(''))
324
- html += section('activity', claimList(data.activity, { when: true }), data.total)
814
+ html += neighborsHtml(data)
815
+ html += section('facts', claimList(data.facts, { self: data.name }), data.facts.length || undefined)
816
+ html += section('relations', fold('as subject', claimList(data.out, { self: data.name }), data.out.length, data.out.length <= 12) +
817
+ fold('as object', data.in.length === 0 ? '<p class="none">none</p>' : data.in.map(function (c) {
818
+ return claimHtml(c, { self: data.name, rel: c.rel })
819
+ }).join(''), data.in.length, data.in.length <= 12))
820
+ html += fold('activity <span class="n">all rows about ' + esc(data.name) + ', newest first</span>', claimList(data.activity, { when: true, self: data.name }) +
821
+ (data.total > data.activity.length ? '<p class="none">showing the newest ' + data.activity.length + ' of ' + data.total + '</p>' : ''), data.total)
325
822
  view.innerHTML = html
823
+ visit(data.name)
824
+ if (relations > 0) neighborhood(data.name, 1)
326
825
  }
327
826
 
328
827
  function topicPage (data) {
329
828
  var html = '<h1>' + esc(data.name) + ' <span class="kind">topic</span></h1>'
829
+ html += '<p class="lede"><a href="#/e/' + enc(data.name) + '">open as entity</a></p>'
330
830
  html += section('members', data.members.length === 0 ? '<p class="none">none</p>' :
331
831
  '<div class="chips">' + data.members.map(function (name) {
332
832
  return isLiteral(name) ? '<span class="chip">' + esc(name) + '</span>' :
333
833
  '<a class="chip" href="#/e/' + enc(name) + '">' + esc(name) + '</a>'
334
834
  }).join('') + '</div>', data.members.length)
835
+ if (data.members.length > 0) {
836
+ html += section('graph', graphPanel('egraph', depthButtons(1) + ' <span class="count"></span>'))
837
+ }
838
+ view.innerHTML = html
839
+ if (data.members.length > 0) neighborhood(data.name, 1)
840
+ }
841
+
842
+ function graphPage (data) {
843
+ var focus = data.focus
844
+ var html = focus === undefined ?
845
+ '<h1>Graph <span class="kind">most connected entities</span></h1>' :
846
+ '<h1>' + esc(focus) + ' <span class="kind">graph</span></h1>'
847
+ html += '<p class="lede">' + (focus === undefined ? '' : '<a href="#/e/' + enc(focus) + '">back to entity</a> &middot; ') +
848
+ 'Current relations between entities; negated ones dashed. Drag to rearrange or pan, hover to trace, click to open.</p>'
849
+ var depth = Math.max.apply(null, data.nodes.map(function (node) { return node.depth || 0 }).concat([1]))
850
+ var hops = location.hash.match(/^#\\/g\\/([1-3])\\//)
851
+ if (hops) depth = +hops[1]
852
+ html += graphPanel('fgraph', (focus === undefined ? '' : depthButtons(depth)) + ' <span class="count">' + graphCount(data) + '</span>')
853
+ html += fold('as a list', data.nodes.length === 0 ? '<p class="none">none</p>' : '<div class="neighbors">' + data.nodes.map(function (node) {
854
+ var out = data.edges.filter(function (edge) { return edge.source === node.name })
855
+ return '<span class="vb"><a href="#/e/' + enc(node.name) + '">' + esc(node.name) + '</a></span><span class="hint">' +
856
+ (out.length === 0 ? '&mdash;' : out.map(function (edge) {
857
+ return (edge.negated ? 'NOT ' : '') + esc(edge.verb) + ' ' + esc(edge.target)
858
+ }).join(', ')) + '</span>'
859
+ }).join('') + '</div>', data.nodes.length)
335
860
  view.innerHTML = html
861
+ Array.prototype.forEach.call(view.querySelectorAll('[data-depth]'), function (button) {
862
+ button.onclick = function () { location.hash = '#/g/' + button.getAttribute('data-depth') + '/' + enc(focus) }
863
+ })
864
+ drawGraph(view.querySelector('#fgraph'), data, { tall: true, label: focus === undefined ? 'map of the most connected entities' : 'relation graph around ' + focus })
336
865
  }
337
866
 
338
867
  function historyPage (data) {
@@ -371,10 +900,19 @@ function lineagePage (data) {
371
900
 
372
901
  function searchPage (text, data) {
373
902
  view.innerHTML = '<h1>' + esc(text) + ' <span class="kind">search</span></h1>' +
903
+ '<div id="names"></div>' +
374
904
  '<p>Search includes earlier revisions and retracted claims, newest first. Open a claim’s history to follow its changes.</p>' +
375
905
  (data.length === 100 ? '<p>Showing the newest 100 matches. More may exist; refine the search phrase to narrow the results.</p>' : '') +
376
906
  section('matches', claimList(data, { when: true }), data.length)
377
907
  }
908
+ /* Entity names matching the phrase, offered above the claim matches. */
909
+ function namesFor (text) {
910
+ later('#names', api('names', { q: text }), function (el, list) {
911
+ el.innerHTML = list.length === 0 ? '' : section('entities', '<div class="chips">' + list.map(function (name) {
912
+ return '<a class="chip" href="#/e/' + enc(name) + '">' + esc(name) + '</a>'
913
+ }).join('') + '</div>', list.length)
914
+ })
915
+ }
378
916
 
379
917
  var navigation = 0
380
918
  var renderedHash
@@ -455,12 +993,26 @@ function renderRoute () {
455
993
  } else if ((match = hash.match(/^#\\/s\\/(.+)$/))) {
456
994
  var text = decodeURIComponent(match[1])
457
995
  if (changedRoute) searchBox.value = text
458
- show(api('search', { q: text }), function (data) { searchPage(text, data) })
996
+ show(api('search', { q: text }), function (data) { searchPage(text, data); namesFor(text) })
997
+ } else if ((match = hash.match(/^#\\/g\\/([1-3])\\/(.+)$/))) {
998
+ show(api('graph', { name: decodeURIComponent(match[2]), depth: match[1], aliases: aliases }), graphPage)
999
+ } else if (/^#\\/g\\/?$/.test(hash)) {
1000
+ show(api('graph'), graphPage)
459
1001
  } else {
460
1002
  show(api('overview'), dashboard)
461
1003
  }
462
1004
  }
463
1005
  window.addEventListener('hashchange', route)
1006
+ // "/" jumps to search from anywhere outside a text field.
1007
+ document.addEventListener('keydown', function (event) {
1008
+ var target = event.target
1009
+ var typing = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
1010
+ if (event.key === '/' && !typing && !event.ctrlKey && !event.metaKey && !event.altKey) {
1011
+ event.preventDefault()
1012
+ searchBox.focus()
1013
+ searchBox.select()
1014
+ }
1015
+ })
464
1016
  route()
465
1017
  </script>
466
1018
  </body>