hypgraph 0.1.0__py3-none-any.whl
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.
- hypgraph/__init__.py +28 -0
- hypgraph/__main__.py +3 -0
- hypgraph/bundle.py +216 -0
- hypgraph/checks.py +140 -0
- hypgraph/cli.py +445 -0
- hypgraph/demos/__init__.py +3 -0
- hypgraph/demos/broad_street.py +283 -0
- hypgraph/derive.py +390 -0
- hypgraph/graph.py +1002 -0
- hypgraph/mcp.py +461 -0
- hypgraph/plan.py +328 -0
- hypgraph/policy.py +51 -0
- hypgraph/schema.py +141 -0
- hypgraph/server.py +202 -0
- hypgraph/skill/SKILL.md +94 -0
- hypgraph/store.py +139 -0
- hypgraph/ui/__init__.py +7 -0
- hypgraph/ui/atlas.css +219 -0
- hypgraph/ui/atlas.js +1052 -0
- hypgraph/ui/hypgraph.svg +10 -0
- hypgraph/ui/index.html +30 -0
- hypgraph/ui/vendor/LICENSES.md +10 -0
- hypgraph/ui/vendor/cose-base.js +3214 -0
- hypgraph/ui/vendor/cytoscape-fcose.js +1549 -0
- hypgraph/ui/vendor/cytoscape.min.js +32 -0
- hypgraph/ui/vendor/layout-base.js +5230 -0
- hypgraph-0.1.0.dist-info/METADATA +140 -0
- hypgraph-0.1.0.dist-info/RECORD +31 -0
- hypgraph-0.1.0.dist-info/WHEEL +4 -0
- hypgraph-0.1.0.dist-info/entry_points.txt +2 -0
- hypgraph-0.1.0.dist-info/licenses/LICENSE +201 -0
hypgraph/ui/atlas.js
ADDED
|
@@ -0,0 +1,1052 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HypGraph Atlas — the research graph explorer. Framework-free, read-only, offline-first.
|
|
3
|
+
*
|
|
4
|
+
* <link rel="stylesheet" href="atlas.css">
|
|
5
|
+
* <script src="vendor/cytoscape.min.js"></script> (+ layout-base, cose-base, cytoscape-fcose for the fcose layout)
|
|
6
|
+
* <script src="atlas.js"></script>
|
|
7
|
+
* <div id="atlas"></div>
|
|
8
|
+
* <script>
|
|
9
|
+
* HypAtlas.mount(document.getElementById('atlas'), { source: HypAtlas.HttpSource('/api/graph') });
|
|
10
|
+
* // or, with no server at all: { source: HypAtlas.BundleSource(bundleJson) }
|
|
11
|
+
* </script>
|
|
12
|
+
*
|
|
13
|
+
* Views: graph (Cytoscape + fcose) · hypotheses (competing explanations × experiments) · claims (every standing
|
|
14
|
+
* dimension, sortable) · provenance (claim → results → runs → exact files; dependence groups) · work (who is on what,
|
|
15
|
+
* queues, duplicates) · timeline (events per day, replay, feed). Nothing here writes. Standing comes from the engine
|
|
16
|
+
* (Python) — a BundleSource shows the standing the bundle was exported with, and re-verifies its hash chain itself.
|
|
17
|
+
*
|
|
18
|
+
* Options: source (required) · operatorUrl (link for actions that live elsewhere) · fileUrl(path) (opens an artifact)
|
|
19
|
+
* · live (poll for new events; default true for HttpSource) · title (brand line) · theme ('dark' | 'light').
|
|
20
|
+
*/
|
|
21
|
+
'use strict';
|
|
22
|
+
(function (global) {
|
|
23
|
+
const VERSION = '0.1.0';
|
|
24
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
25
|
+
|
|
26
|
+
// ── vocabulary ────────────────────────────────────────────────────
|
|
27
|
+
const TYPES = {
|
|
28
|
+
investigation: { color: '#ffb547', shape: 'star', label: 'Investigation', size: 46 },
|
|
29
|
+
question: { color: '#2fe6d6', shape: 'diamond', label: 'Question', size: 30 },
|
|
30
|
+
hypothesis: { color: '#b18cff', shape: 'hexagon', label: 'Hypothesis', size: 30 },
|
|
31
|
+
claim: { color: '#4ee08a', shape: 'round-rectangle', label: 'Claim', size: 24 },
|
|
32
|
+
experiment: { color: '#ff7ab6', shape: 'round-triangle', label: 'Experiment', size: 28 },
|
|
33
|
+
run: { color: '#4aa8ff', shape: 'barrel', label: 'Run', size: 18 },
|
|
34
|
+
result: { color: '#8ee6ff', shape: 'ellipse', label: 'Result', size: 20 },
|
|
35
|
+
artifact: { color: '#94a3b8', shape: 'rectangle', label: 'Artifact', size: 14 },
|
|
36
|
+
source: { color: '#ffd166', shape: 'tag', label: 'Source', size: 20 },
|
|
37
|
+
review: { color: '#cbd5e1', shape: 'ellipse', label: 'Review', size: 9 },
|
|
38
|
+
decision: { color: '#ff9f43', shape: 'octagon', label: 'Decision', size: 28 },
|
|
39
|
+
task: { color: '#7fb88f', shape: 'vee', label: 'Task', size: 18 },
|
|
40
|
+
};
|
|
41
|
+
const EDGES = {
|
|
42
|
+
supports: { color: '#22c55e', style: 'solid', arrow: 'triangle', label: 'supports' },
|
|
43
|
+
challenges: { color: '#ff4d5e', style: 'solid', arrow: 'tee', label: 'challenges' },
|
|
44
|
+
requires: { color: '#ffb547', style: 'dotted', arrow: 'triangle', label: 'requires (rests on)' },
|
|
45
|
+
derived_from: { color: '#7c8ea3', style: 'solid', arrow: 'triangle', label: 'derived from' },
|
|
46
|
+
produced: { color: '#4aa8ff', style: 'solid', arrow: 'triangle', label: 'produced' },
|
|
47
|
+
uses: { color: '#4aa8ff', style: 'dashed', arrow: 'triangle', label: 'uses (data / code)' },
|
|
48
|
+
tests: { color: '#b18cff', style: 'solid', arrow: 'triangle', label: 'tests' },
|
|
49
|
+
executes: { color: '#ff7ab6', style: 'dashed', arrow: 'triangle', label: 'executes' },
|
|
50
|
+
replicates: { color: '#2fe6d6', style: 'solid', arrow: 'triangle-tee', label: 'replicates' },
|
|
51
|
+
supersedes: { color: '#94a3b8', style: 'dashed', arrow: 'triangle', label: 'supersedes / corrects' },
|
|
52
|
+
corrects: { color: '#94a3b8', style: 'dashed', arrow: 'triangle', label: null },
|
|
53
|
+
reviews: { color: '#cbd5e1', style: 'dotted', arrow: 'circle', label: 'reviews' },
|
|
54
|
+
based_on: { color: '#ff9f43', style: 'dotted', arrow: 'triangle', label: 'based on (decision)' },
|
|
55
|
+
other: { color: '#56657a', style: 'solid', arrow: 'triangle', label: 'other' },
|
|
56
|
+
};
|
|
57
|
+
const STANDING = [
|
|
58
|
+
['checked', 'checked'], ['unchecked', 'unchecked'], ['needs_revalidation', 'review stale'],
|
|
59
|
+
['contested', 'contested'], ['at_risk', 'at risk'], ['withdrawn', 'withdrawn'], ['superseded', 'superseded'],
|
|
60
|
+
];
|
|
61
|
+
const RISKY = ['requires', 'derived_from', 'based_on'];
|
|
62
|
+
|
|
63
|
+
// ── hashing (WebCrypto, with a small fallback for insecure contexts) ─────
|
|
64
|
+
async function sha256(text) {
|
|
65
|
+
const bytes = new TextEncoder().encode(text);
|
|
66
|
+
if (global.crypto && global.crypto.subtle) {
|
|
67
|
+
const d = await global.crypto.subtle.digest('SHA-256', bytes);
|
|
68
|
+
return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
69
|
+
}
|
|
70
|
+
return sha256Fallback(bytes);
|
|
71
|
+
}
|
|
72
|
+
function sha256Fallback(bytes) {
|
|
73
|
+
const K = new Uint32Array([0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
74
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786,
|
|
75
|
+
0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
|
|
76
|
+
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb,
|
|
77
|
+
0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
78
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f,
|
|
79
|
+
0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2]);
|
|
80
|
+
const H = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
|
|
81
|
+
const l = bytes.length, n = ((l + 9 + 63) >> 6) << 6, m = new Uint8Array(n);
|
|
82
|
+
m.set(bytes); m[l] = 0x80;
|
|
83
|
+
const dv = new DataView(m.buffer); dv.setUint32(n - 4, l * 8); dv.setUint32(n - 8, Math.floor(l / 0x20000000));
|
|
84
|
+
const w = new Uint32Array(64), r = (x, k) => (x >>> k) | (x << (32 - k));
|
|
85
|
+
for (let o = 0; o < n; o += 64) {
|
|
86
|
+
for (let i = 0; i < 16; i++) w[i] = dv.getUint32(o + 4 * i);
|
|
87
|
+
for (let i = 16; i < 64; i++) {
|
|
88
|
+
const s0 = r(w[i - 15], 7) ^ r(w[i - 15], 18) ^ (w[i - 15] >>> 3), s1 = r(w[i - 2], 17) ^ r(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
|
89
|
+
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
|
|
90
|
+
}
|
|
91
|
+
let [a, b, c, d, e, f, g, h] = H;
|
|
92
|
+
for (let i = 0; i < 64; i++) {
|
|
93
|
+
const t1 = (h + (r(e, 6) ^ r(e, 11) ^ r(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
|
|
94
|
+
const t2 = ((r(a, 2) ^ r(a, 13) ^ r(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
|
|
95
|
+
h = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
|
|
96
|
+
}
|
|
97
|
+
H[0] += a; H[1] += b; H[2] += c; H[3] += d; H[4] += e; H[5] += f; H[6] += g; H[7] += h;
|
|
98
|
+
}
|
|
99
|
+
return [...H].map((x) => x.toString(16).padStart(8, '0')).join('');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Verify a hypgraph/1-bundle string-for-string — the same checks as hypgraph.verify_bundle in Python. */
|
|
103
|
+
async function verifyBundle(b) {
|
|
104
|
+
if (!b || b.format !== 'hypgraph/1-bundle') return { ok: false, problem: 'not a hypgraph/1-bundle' };
|
|
105
|
+
let prev = '0'.repeat(64), checked = 0, red = 0;
|
|
106
|
+
const creates = new Map(), revs = new Map(), reviews = new Map(), links = new Map(), withdraws = new Set(), retracts = new Set();
|
|
107
|
+
for (const ev of [...(b.events || [])].sort((x, y) => x.seq - y.seq)) {
|
|
108
|
+
if (ev.redacted) {
|
|
109
|
+
if (ev.prev !== prev) return { ok: false, broken_at: ev.seq, problem: 'chain broken (redacted event)' };
|
|
110
|
+
prev = ev.hash; red += 1; continue;
|
|
111
|
+
}
|
|
112
|
+
if (await sha256(ev.body) !== ev.hash) return { ok: false, broken_at: ev.seq, problem: 'event body does not match its hash' };
|
|
113
|
+
const body = JSON.parse(ev.body);
|
|
114
|
+
if (body.prev !== prev) return { ok: false, broken_at: ev.seq, problem: 'chain broken (prev does not match)' };
|
|
115
|
+
prev = ev.hash; checked += 1;
|
|
116
|
+
const p = body.payload || {};
|
|
117
|
+
if (body.kind === 'create') { creates.set(body.ref, p); revs.set(`${body.ref}#${p.v || 1}`, p.body_sha); }
|
|
118
|
+
else if (body.kind === 'revise') revs.set(`${body.ref}#${p.v}`, p.body_sha);
|
|
119
|
+
else if (body.kind === 'review') { reviews.set(p.review, p); revs.set(`${p.review}#1`, p.body_sha); }
|
|
120
|
+
else if (body.kind === 'link') links.set(Number(p.edge), p);
|
|
121
|
+
else if (body.kind === 'withdraw') withdraws.add(body.ref);
|
|
122
|
+
else if (body.kind === 'retract') retracts.add(Number(p.edge));
|
|
123
|
+
}
|
|
124
|
+
if (b.head && b.head !== prev) return { ok: false, problem: "the chain does not end at the bundle's head" };
|
|
125
|
+
for (const n of b.nodes || []) {
|
|
126
|
+
const c = creates.get(n.id);
|
|
127
|
+
if (n.type === 'review') { if (!reviews.has(n.id)) return { ok: false, problem: `review ${n.id} has no event` }; }
|
|
128
|
+
else if (!c || c.type !== n.type || c.code !== n.code || c.inv !== n.inv) return { ok: false, problem: `node ${n.id} differs from its create event` };
|
|
129
|
+
}
|
|
130
|
+
for (const v of b.versions || []) {
|
|
131
|
+
const want = revs.get(`${v.node}#${v.v}`);
|
|
132
|
+
if (want === undefined) return { ok: false, problem: `version ${v.node} v${v.v} has no event` };
|
|
133
|
+
if (await sha256(v.body) !== want) return { ok: false, problem: `version ${v.node} v${v.v} differs from its event` };
|
|
134
|
+
}
|
|
135
|
+
for (const e of b.edges || []) {
|
|
136
|
+
const want = links.get(Number(e.id));
|
|
137
|
+
if (!want) return { ok: false, problem: `edge ${e.id} has no event` };
|
|
138
|
+
if (want.rel !== e.rel || want.dst !== e.dst || (want.dst_v ?? null) !== (e.dst_v ?? null) || await sha256(e.data) !== want.data_sha)
|
|
139
|
+
return { ok: false, problem: `edge ${e.id} differs from its event` };
|
|
140
|
+
}
|
|
141
|
+
for (const w of b.withdrawals || []) if (!withdraws.has(w.node)) return { ok: false, problem: `withdrawal of ${w.node} has no event` };
|
|
142
|
+
for (const r of b.retractions || []) if (!retracts.has(Number(r.edge))) return { ok: false, problem: `retraction of edge ${r.edge} has no event` };
|
|
143
|
+
const out = { ok: true, checked, head: prev, complete: red === 0, redacted: red, nodes: (b.nodes || []).length, edges: (b.edges || []).length };
|
|
144
|
+
if (red) out.warning = `${red} event(s) outside this investigation are redacted: their content can't be checked.`;
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── data sources ──────────────────────────────────────────────────
|
|
149
|
+
/** Live: the JSON API of `hypgraph serve`, or any host that serves the same routes (PegasusDMZ's /api/graph). */
|
|
150
|
+
function HttpSource(base = '/api/graph', fetchOpts = {}) {
|
|
151
|
+
const get = async (path) => {
|
|
152
|
+
const r = await fetch(base + path, fetchOpts);
|
|
153
|
+
let body = null;
|
|
154
|
+
try { body = await r.json(); } catch { /* not json */ }
|
|
155
|
+
if (!r.ok) throw new Error((body && (body.detail?.error || body.detail || body.error)) || `HTTP ${r.status}`);
|
|
156
|
+
return body;
|
|
157
|
+
};
|
|
158
|
+
const q = encodeURIComponent;
|
|
159
|
+
return {
|
|
160
|
+
kind: 'http', live: true,
|
|
161
|
+
investigations: () => get('/investigations'),
|
|
162
|
+
subgraph: (inv) => get(`/subgraph?inv=${q(inv)}`),
|
|
163
|
+
node: (id) => get(`/node/${q(id)}?history=1`),
|
|
164
|
+
why: (id) => get(`/why/${q(id)}`),
|
|
165
|
+
impact: (id) => get(`/impact/${q(id)}`),
|
|
166
|
+
events: (after, inv, limit) => get(`/events?after=${after || 0}&inv=${q(inv || '')}&limit=${limit || 500}`),
|
|
167
|
+
next: (inv) => get(`/next?inv=${q(inv)}`),
|
|
168
|
+
work: (inv) => get(`/work?inv=${q(inv)}`),
|
|
169
|
+
cost: (inv) => get(`/cost?inv=${q(inv)}`),
|
|
170
|
+
verify: () => get('/verify'),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Static: a hypgraph/1-bundle (from `hypgraph export`) — no server, no Python. */
|
|
175
|
+
function BundleSource(bundle) {
|
|
176
|
+
const d = bundle.derived || { by_inv: {}, investigations: [], stats: {} };
|
|
177
|
+
const nodes = new Map(), edges = [];
|
|
178
|
+
Object.values(d.by_inv || {}).forEach((v) => {
|
|
179
|
+
v.subgraph.nodes.forEach((n) => nodes.set(n.id, n));
|
|
180
|
+
v.subgraph.edges.forEach((e) => edges.push(e));
|
|
181
|
+
});
|
|
182
|
+
const out = new Map(), inn = new Map();
|
|
183
|
+
edges.forEach((e) => { (out.get(e.src) || out.set(e.src, []).get(e.src)).push(e); (inn.get(e.dst) || inn.set(e.dst, []).get(e.dst)).push(e); });
|
|
184
|
+
const versions = new Map();
|
|
185
|
+
(bundle.versions || []).forEach((v) => { (versions.get(v.node) || versions.set(v.node, []).get(v.node)).push({ ...v, body: JSON.parse(v.body) }); });
|
|
186
|
+
versions.forEach((list) => list.sort((a, b) => a.v - b.v));
|
|
187
|
+
const withdrawn = new Map((bundle.withdrawals || []).map((w) => [w.node, w]));
|
|
188
|
+
const evs = (bundle.events || []).filter((e) => !e.redacted).map((e) => { const b = JSON.parse(e.body); return { seq: e.seq, at: b.at, actor: b.actor, kind: b.kind, ref: b.ref, payload: b.payload }; });
|
|
189
|
+
const head = evs.length ? evs[evs.length - 1].seq : 0;
|
|
190
|
+
const why = (id) => {
|
|
191
|
+
const outRels = ['derived_from', 'requires', 'based_on', 'executes', 'uses', 'cites', 'tests'], inRels = ['supports', 'produced'];
|
|
192
|
+
const seen = new Set([id]); let frontier = [id]; const es = [];
|
|
193
|
+
while (frontier.length && seen.size < 300) {
|
|
194
|
+
const nxt = [];
|
|
195
|
+
frontier.forEach((x) => {
|
|
196
|
+
const ups = (out.get(x) || []).filter((e) => outRels.includes(e.rel)).map((e) => [e, e.dst])
|
|
197
|
+
.concat((inn.get(x) || []).filter((e) => inRels.includes(e.rel)).map((e) => [e, e.src]));
|
|
198
|
+
ups.forEach(([e, y]) => { es.push(e); if (!seen.has(y)) { seen.add(y); nxt.push(y); } });
|
|
199
|
+
});
|
|
200
|
+
frontier = nxt;
|
|
201
|
+
}
|
|
202
|
+
return { center: id, nodes: [...seen].map((x) => nodes.get(x)).filter(Boolean), edges: es };
|
|
203
|
+
};
|
|
204
|
+
const impact = (id) => {
|
|
205
|
+
const step = (x) => (inn.get(x) || []).filter((e) => RISKY.includes(e.rel)).map((e) => [e.src, e.rel])
|
|
206
|
+
.concat((out.get(x) || []).filter((e) => e.rel === 'produced').map((e) => [e.dst, 'produced']))
|
|
207
|
+
.concat((out.get(x) || []).filter((e) => e.rel === 'supports').map((e) => [e.dst, 'supports']));
|
|
208
|
+
const seen = new Set([id]), dep = []; let frontier = [[id, 0]];
|
|
209
|
+
while (frontier.length && dep.length < 500) {
|
|
210
|
+
const nxt = [];
|
|
211
|
+
frontier.forEach(([x, k]) => step(x).forEach(([y, rel]) => { if (!seen.has(y)) { seen.add(y); dep.push({ node: nodes.get(y), rel, depth: k + 1 }); nxt.push([y, k + 1]); } }));
|
|
212
|
+
frontier = nxt;
|
|
213
|
+
}
|
|
214
|
+
return { center: id, dependents: dep.filter((x) => x.node), edges: edges.filter((e) => seen.has(e.src) && seen.has(e.dst)) };
|
|
215
|
+
};
|
|
216
|
+
let verified = null;
|
|
217
|
+
return {
|
|
218
|
+
kind: 'bundle', live: false, bundle,
|
|
219
|
+
investigations: async () => ({ investigations: d.investigations || [], stats: d.stats || {} }),
|
|
220
|
+
subgraph: async (inv) => d.by_inv[inv].subgraph,
|
|
221
|
+
node: async (id) => {
|
|
222
|
+
const n = nodes.get(id); if (!n) throw new Error(`no node ${id}`);
|
|
223
|
+
const vs = versions.get(id) || [];
|
|
224
|
+
return { ...n, body: vs.length ? vs[vs.length - 1].body : {}, versions: vs, out: out.get(id) || [], in: inn.get(id) || [],
|
|
225
|
+
...(withdrawn.has(id) ? { withdrawn: withdrawn.get(id) } : {}) };
|
|
226
|
+
},
|
|
227
|
+
why: async (id) => why(id),
|
|
228
|
+
impact: async (id) => impact(id),
|
|
229
|
+
events: async (after, inv) => ({ head, events: evs.filter((e) => e.seq > (after || 0) && (!inv || e.ref === inv || (nodes.get(e.ref) || {}).inv === inv)) }),
|
|
230
|
+
next: async (inv) => d.by_inv[inv].next,
|
|
231
|
+
work: async (inv) => d.by_inv[inv].work,
|
|
232
|
+
cost: async () => null,
|
|
233
|
+
verify: async () => (verified = verified || await verifyBundle(bundle)),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const MARKUP = (o) => `
|
|
238
|
+
<header class="at-bar">
|
|
239
|
+
<div class="at-brand">${esc(o.title || 'ATLAS')} <span>// research graph</span></div>
|
|
240
|
+
<label class="at-inv-wrap" title="Investigation"><span class="at-k">INVESTIGATION</span>
|
|
241
|
+
<select id="at-inv" aria-label="Investigation"></select></label>
|
|
242
|
+
<nav class="at-tabs" role="tablist" aria-label="Views">
|
|
243
|
+
<button role="tab" data-view="graph" class="on" title="Graph (1)">Graph</button>
|
|
244
|
+
<button role="tab" data-view="hypotheses" title="Hypotheses (2)">Hypotheses</button>
|
|
245
|
+
<button role="tab" data-view="claims" title="Claims (3)">Claims</button>
|
|
246
|
+
<button role="tab" data-view="provenance" title="Provenance (4)">Provenance</button>
|
|
247
|
+
<button role="tab" data-view="work" title="Work (5)">Work</button>
|
|
248
|
+
<button role="tab" data-view="timeline" title="Timeline (6)">Timeline</button>
|
|
249
|
+
</nav>
|
|
250
|
+
<div class="at-search">
|
|
251
|
+
<input id="at-q" type="search" placeholder="Search code or words ( / )" autocomplete="off" aria-label="Search">
|
|
252
|
+
<div class="at-results" id="at-results" hidden></div>
|
|
253
|
+
</div>
|
|
254
|
+
<div class="at-bar-right">
|
|
255
|
+
<span class="at-chip" id="at-chain" title="Hash chain over every write">chain …</span>
|
|
256
|
+
<span class="at-chip at-live" id="at-live" title="Live — polls the event log">LIVE</span>
|
|
257
|
+
<button class="at-btn" id="at-theme" title="Light / dark" aria-label="Toggle theme">◐</button>
|
|
258
|
+
<button class="at-btn" id="at-export" title="Export PNG + JSON" aria-label="Export">⇩</button>
|
|
259
|
+
</div>
|
|
260
|
+
</header>
|
|
261
|
+
<div class="at-body">
|
|
262
|
+
<aside class="at-rail" aria-label="Filters">
|
|
263
|
+
<section class="at-sec"><h3>Node types</h3><div id="at-types" class="at-types"></div></section>
|
|
264
|
+
<section class="at-sec"><h3>Standing</h3><div id="at-standing" class="at-checks"></div></section>
|
|
265
|
+
<section class="at-sec"><h3>Show</h3>
|
|
266
|
+
<label class="at-toggle"><input type="checkbox" id="at-show-reviews"> reviews</label>
|
|
267
|
+
<label class="at-toggle"><input type="checkbox" id="at-show-withdrawn" checked> withdrawn</label>
|
|
268
|
+
<label class="at-toggle"><input type="checkbox" id="at-show-labels" checked> labels</label></section>
|
|
269
|
+
<section class="at-sec"><h3>Edges</h3><div id="at-edge-legend" class="at-legend"></div></section>
|
|
270
|
+
<section class="at-sec"><h3>Ring = review</h3><div class="at-legend">
|
|
271
|
+
<div><i class="ring checked"></i>checked</div><div><i class="ring unchecked"></i>unchecked</div>
|
|
272
|
+
<div><i class="ring needs_revalidation"></i>review stale</div><div><i class="ring contested"></i>contested</div>
|
|
273
|
+
<div><i class="ring at-risk"></i>at risk (glow)</div></div></section>
|
|
274
|
+
<section class="at-sec at-stats" id="at-stats"></section>
|
|
275
|
+
</aside>
|
|
276
|
+
<main class="at-main">
|
|
277
|
+
<section class="at-view on" data-view="graph">
|
|
278
|
+
<div id="at-cy" class="at-cy" aria-label="Graph canvas"></div>
|
|
279
|
+
<div class="at-hud">
|
|
280
|
+
<select id="at-layout" aria-label="Layout"><option value="fcose">force (fcose)</option>
|
|
281
|
+
<option value="breadthfirst">evidence tiers</option><option value="concentric">by degree</option>
|
|
282
|
+
<option value="cose">force (cose)</option></select>
|
|
283
|
+
<label class="at-k" for="at-depth">focus</label>
|
|
284
|
+
<input type="range" id="at-depth" min="1" max="4" value="2" aria-label="Focus depth"><span id="at-depth-v">2</span>
|
|
285
|
+
<button class="at-btn" id="at-fit">Fit</button>
|
|
286
|
+
<button class="at-btn" id="at-why" disabled title="What it rests on (w)">Why</button>
|
|
287
|
+
<button class="at-btn" id="at-impact" disabled title="What rests on it (i)">Impact</button>
|
|
288
|
+
<button class="at-btn" id="at-clear" title="Clear focus (Esc)">Clear</button>
|
|
289
|
+
<span class="at-hint" id="at-hint">click: inspect · double-click: focus · scroll: zoom</span>
|
|
290
|
+
</div>
|
|
291
|
+
<div class="at-tip" id="at-tip" hidden></div>
|
|
292
|
+
</section>
|
|
293
|
+
<section class="at-view" data-view="hypotheses"><div class="at-scroll" id="at-hyp"></div></section>
|
|
294
|
+
<section class="at-view" data-view="claims">
|
|
295
|
+
<div class="at-table-bar"><input id="at-claim-filter" type="search" placeholder="Filter claims…" aria-label="Filter claims">
|
|
296
|
+
<span id="at-claim-count" class="at-k"></span></div>
|
|
297
|
+
<div class="at-scroll"><table class="at-table" id="at-claims"></table></div>
|
|
298
|
+
</section>
|
|
299
|
+
<section class="at-view" data-view="provenance"><div class="at-scroll" id="at-prov"></div></section>
|
|
300
|
+
<section class="at-view" data-view="work"><div class="at-scroll" id="at-work"></div></section>
|
|
301
|
+
<section class="at-view" data-view="timeline">
|
|
302
|
+
<div class="at-tl-top"><button class="at-btn" id="at-tl-play" aria-label="Replay">▶ Replay</button>
|
|
303
|
+
<input type="range" id="at-tl-range" min="0" max="100" value="100" aria-label="Point in time"><span id="at-tl-at" class="at-k"></span></div>
|
|
304
|
+
<svg id="at-tl-chart" class="at-tl-chart" role="img" aria-label="Events per day"></svg>
|
|
305
|
+
<div class="at-scroll at-feed" id="at-tl-feed"></div>
|
|
306
|
+
</section>
|
|
307
|
+
</main>
|
|
308
|
+
<aside class="at-insp" id="at-insp" aria-label="Inspector">
|
|
309
|
+
<div class="at-empty">Select a node to inspect it — its standing, fields, links, versions and provenance.</div>
|
|
310
|
+
</aside>
|
|
311
|
+
</div>
|
|
312
|
+
<div class="at-toast" id="at-toast" hidden></div>`;
|
|
313
|
+
|
|
314
|
+
// ── the explorer ──────────────────────────────────────────────────
|
|
315
|
+
function mount(root, opts) {
|
|
316
|
+
opts = opts || {};
|
|
317
|
+
const src = opts.source || HttpSource();
|
|
318
|
+
const OPERATOR = opts.operatorUrl || '';
|
|
319
|
+
root.classList.add('atlas');
|
|
320
|
+
if (!root.dataset.theme) root.dataset.theme = opts.theme || 'dark';
|
|
321
|
+
root.innerHTML = MARKUP(opts);
|
|
322
|
+
const $ = (s) => root.querySelector(s);
|
|
323
|
+
const $$ = (s) => [...root.querySelectorAll(s)];
|
|
324
|
+
const live = opts.live ?? src.live;
|
|
325
|
+
if (!live) $('#at-live').remove();
|
|
326
|
+
|
|
327
|
+
const S = {
|
|
328
|
+
inv: '', invs: [], data: { nodes: [], edges: [] }, by: new Map(), view: 'graph', cy: null, sel: null,
|
|
329
|
+
types: new Set(Object.keys(TYPES).filter((t) => t !== 'review')), standing: new Set(STANDING.map((s) => s[0])),
|
|
330
|
+
showReviews: false, showWithdrawn: true, labels: true, head: 0, events: [], until: null, sort: { k: 'code', asc: true },
|
|
331
|
+
details: new Map(),
|
|
332
|
+
};
|
|
333
|
+
async function detail(id) {
|
|
334
|
+
if (!S.details.has(id)) S.details.set(id, await src.node(id));
|
|
335
|
+
return S.details.get(id);
|
|
336
|
+
}
|
|
337
|
+
function toast(msg, ms = 2600) {
|
|
338
|
+
const t = $('#at-toast');
|
|
339
|
+
t.textContent = msg; t.hidden = false;
|
|
340
|
+
clearTimeout(toast._t); toast._t = setTimeout(() => { t.hidden = true; }, ms);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function standing(n) {
|
|
344
|
+
const st = n.state || {};
|
|
345
|
+
if (st.lifecycle === 'withdrawn') return 'withdrawn';
|
|
346
|
+
if (st.lifecycle === 'superseded') return 'superseded';
|
|
347
|
+
if (st.at_risk) return 'at_risk';
|
|
348
|
+
return (st.review && st.review.state) || 'unchecked';
|
|
349
|
+
}
|
|
350
|
+
const reviewState = (n) => (n.state && n.state.review && n.state.review.state) || 'none';
|
|
351
|
+
const code = (n) => n.code || n.id;
|
|
352
|
+
const day = (iso) => (iso || '').slice(0, 10);
|
|
353
|
+
const count = (i) => Object.values(i.counts || {}).reduce((a, x) => a + x, 0);
|
|
354
|
+
|
|
355
|
+
// ── investigations ─────────────────────────────────────────
|
|
356
|
+
async function loadInvestigations() {
|
|
357
|
+
const r = await src.investigations();
|
|
358
|
+
S.invs = r.investigations || [];
|
|
359
|
+
const sel = $('#at-inv');
|
|
360
|
+
sel.innerHTML = S.invs.map((i) => {
|
|
361
|
+
const b = i.body || {};
|
|
362
|
+
return `<option value="${esc(i.id)}">${esc(b.title || i.id)} · ${esc(b.state || 'active')} · ${count(i)}</option>`;
|
|
363
|
+
}).join('') || '<option value="">(no investigations yet)</option>';
|
|
364
|
+
const fromHash = new URLSearchParams(location.hash.slice(1));
|
|
365
|
+
const want = opts.inv || fromHash.get('inv');
|
|
366
|
+
const pick = S.invs.find((i) => i.id === want || i.code === want)
|
|
367
|
+
|| S.invs.filter((i) => (i.body || {}).state !== 'archived').sort((a, b) => count(b) - count(a))[0]
|
|
368
|
+
|| S.invs[0];
|
|
369
|
+
if (pick) { sel.value = pick.id; await loadInv(pick.id, fromHash.get('node')); }
|
|
370
|
+
const st = r.stats || {};
|
|
371
|
+
$('#at-stats').innerHTML = `graph: ${st.nodes ?? '–'} nodes · ${st.edges ?? '–'} edges<br>events: ${st.seq ?? '–'} · leases: ${st.leases ?? 0}`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function loadInv(inv, focusNode) {
|
|
375
|
+
S.inv = inv; S.details.clear(); S.sel = null;
|
|
376
|
+
$('#at-insp').innerHTML = '<div class="at-empty">Loading…</div>';
|
|
377
|
+
try { S.data = await src.subgraph(inv); } catch (e) {
|
|
378
|
+
$('#at-insp').innerHTML = `<div class="at-empty">Couldn't load the graph: ${esc(e.message)}</div>`; return;
|
|
379
|
+
}
|
|
380
|
+
S.by = new Map(S.data.nodes.map((n) => [n.id, n]));
|
|
381
|
+
S.head = Math.max(S.head, S.data.seq || 0);
|
|
382
|
+
if (opts.hash !== false) history.replaceState(null, '', `#inv=${encodeURIComponent(inv)}`);
|
|
383
|
+
renderRail(); S.events = []; render();
|
|
384
|
+
showInspector(focusNode || inv);
|
|
385
|
+
verifyChain();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function verifyChain() {
|
|
389
|
+
const chip = $('#at-chain');
|
|
390
|
+
try {
|
|
391
|
+
const v = await src.verify();
|
|
392
|
+
chip.textContent = v.ok ? `chain ✓ ${v.checked}` : 'chain BROKEN';
|
|
393
|
+
chip.className = 'at-chip ' + (v.ok ? 'ok' : 'bad');
|
|
394
|
+
chip.title = v.ok ? `Hash chain verified over ${v.checked} events${src.kind === 'bundle' ? ' in your browser' : ''}; every version and edge matches its event.${v.warning ? ' ' + v.warning : ''}`
|
|
395
|
+
: `Integrity check failed: ${v.problem || 'at event ' + v.broken_at}`;
|
|
396
|
+
} catch (e) { chip.textContent = 'chain ?'; chip.title = e.message; }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── rail ──────────────────────────────────────────────────
|
|
400
|
+
function renderRail() {
|
|
401
|
+
const counts = {};
|
|
402
|
+
S.data.nodes.forEach((n) => { counts[n.type] = (counts[n.type] || 0) + 1; });
|
|
403
|
+
$('#at-types').innerHTML = Object.entries(TYPES).filter(([t]) => counts[t]).map(([t, v]) => `
|
|
404
|
+
<label><input type="checkbox" data-type="${t}" ${S.types.has(t) ? 'checked' : ''}>
|
|
405
|
+
<span class="sw" style="background:${v.color}"></span>${v.label}<span class="n">${counts[t]}</span></label>`).join('');
|
|
406
|
+
const sc = {};
|
|
407
|
+
S.data.nodes.forEach((n) => { const k = standing(n); sc[k] = (sc[k] || 0) + 1; });
|
|
408
|
+
$('#at-standing').innerHTML = STANDING.map(([k, l]) => `
|
|
409
|
+
<label><input type="checkbox" data-standing="${k}" ${S.standing.has(k) ? 'checked' : ''}>${l}<span class="n">${sc[k] || 0}</span></label>`).join('');
|
|
410
|
+
$('#at-edge-legend').innerHTML = Object.values(EDGES).filter((e) => e.label).map((e) =>
|
|
411
|
+
`<div><i class="ln ${e.style === 'dotted' ? 'dot' : e.style === 'dashed' ? 'dash' : ''}" style="border-color:${e.color}"></i>${e.label}</div>`).join('');
|
|
412
|
+
}
|
|
413
|
+
$('#at-types').addEventListener('change', (ev) => {
|
|
414
|
+
const t = ev.target.dataset.type; if (!t) return;
|
|
415
|
+
ev.target.checked ? S.types.add(t) : S.types.delete(t); render();
|
|
416
|
+
});
|
|
417
|
+
$('#at-standing').addEventListener('change', (ev) => {
|
|
418
|
+
const k = ev.target.dataset.standing; if (!k) return;
|
|
419
|
+
ev.target.checked ? S.standing.add(k) : S.standing.delete(k); render();
|
|
420
|
+
});
|
|
421
|
+
$('#at-show-reviews').addEventListener('change', (ev) => {
|
|
422
|
+
S.showReviews = ev.target.checked;
|
|
423
|
+
ev.target.checked ? S.types.add('review') : S.types.delete('review'); renderRail(); render();
|
|
424
|
+
});
|
|
425
|
+
$('#at-show-withdrawn').addEventListener('change', (ev) => {
|
|
426
|
+
S.showWithdrawn = ev.target.checked;
|
|
427
|
+
ev.target.checked ? S.standing.add('withdrawn') : S.standing.delete('withdrawn'); renderRail(); render();
|
|
428
|
+
});
|
|
429
|
+
$('#at-show-labels').addEventListener('change', (ev) => { S.labels = ev.target.checked; if (S.cy) S.cy.style(cyStyle()).update(); });
|
|
430
|
+
|
|
431
|
+
function visible(n) {
|
|
432
|
+
if (!S.types.has(n.type)) return false;
|
|
433
|
+
const k = standing(n);
|
|
434
|
+
// the standing filter governs evidence-bearing nodes, and anything withdrawn or superseded
|
|
435
|
+
const governed = ['claim', 'result', 'hypothesis', 'experiment'].includes(n.type) || k === 'withdrawn' || k === 'superseded';
|
|
436
|
+
if (governed && !S.standing.has(k)) return false;
|
|
437
|
+
if (S.until && n.at > S.until) return false;
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ── views ─────────────────────────────────────────────────
|
|
442
|
+
function setView(v) {
|
|
443
|
+
S.view = v;
|
|
444
|
+
$$('.at-tabs button').forEach((b) => b.classList.toggle('on', b.dataset.view === v));
|
|
445
|
+
$$('.at-view').forEach((s) => s.classList.toggle('on', s.dataset.view === v));
|
|
446
|
+
// Back on the graph with nothing changed: keep the layout and only tell Cytoscape it is visible again.
|
|
447
|
+
if (v === 'graph' && S.cy && S.cyStamp === graphStamp()) { S.cy.resize(); return; }
|
|
448
|
+
render();
|
|
449
|
+
}
|
|
450
|
+
function graphStamp() {
|
|
451
|
+
return JSON.stringify([S.inv, S.data.seq, S.data.nodes.length, [...S.types].sort(), [...S.standing].sort(),
|
|
452
|
+
S.showReviews, S.showWithdrawn, S.labels, S.until, $('#at-layout').value]);
|
|
453
|
+
}
|
|
454
|
+
$('.at-tabs').addEventListener('click', (ev) => { const v = ev.target.dataset.view; if (v) setView(v); });
|
|
455
|
+
|
|
456
|
+
function render() {
|
|
457
|
+
if (S.view === 'graph') renderGraph();
|
|
458
|
+
else if (S.view === 'hypotheses') renderHypotheses();
|
|
459
|
+
else if (S.view === 'claims') renderClaims();
|
|
460
|
+
else if (S.view === 'provenance') renderProvenance();
|
|
461
|
+
else if (S.view === 'work') renderWork();
|
|
462
|
+
else if (S.view === 'timeline') renderTimeline();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ── graph ─────────────────────────────────────────────────
|
|
466
|
+
function css(name, fallback) { return getComputedStyle(root).getPropertyValue(name).trim() || fallback; }
|
|
467
|
+
function cyStyle() {
|
|
468
|
+
const light = root.dataset.theme === 'light';
|
|
469
|
+
const ink = css('--a-ink', light ? '#0d1321' : '#e6ecf5');
|
|
470
|
+
const ink2 = css('--a-ink3', light ? '#8a96a8' : '#56657a');
|
|
471
|
+
const good = css('--a-good', '#3ddc84'), warn = css('--a-warn', '#ffb547'), bad = css('--a-bad', '#ff4d5e');
|
|
472
|
+
return [
|
|
473
|
+
{ selector: 'node', style: {
|
|
474
|
+
'background-color': 'data(color)', shape: 'data(shape)', width: 'data(size)', height: 'data(size)',
|
|
475
|
+
label: S.labels ? 'data(label)' : '', color: ink, 'font-family': css('--a-mono', 'ui-monospace, monospace'), 'font-size': 9,
|
|
476
|
+
'text-valign': 'bottom', 'text-margin-y': 3, 'min-zoomed-font-size': 7, 'border-width': 0,
|
|
477
|
+
'text-outline-color': css('--a-bg', light ? '#f7f8fb' : '#0a0d14'), 'text-outline-width': 2 } },
|
|
478
|
+
{ selector: 'node[review = "checked"]', style: { 'border-width': 3, 'border-color': good } },
|
|
479
|
+
{ selector: 'node[review = "unchecked"]', style: { 'border-width': 2, 'border-color': ink2, 'border-style': 'dashed' } },
|
|
480
|
+
{ selector: 'node[review = "needs_revalidation"]', style: { 'border-width': 3, 'border-color': warn, 'border-style': 'dashed' } },
|
|
481
|
+
{ selector: 'node[review = "contested"]', style: { 'border-width': 4, 'border-color': bad } },
|
|
482
|
+
{ selector: 'node.risk', style: { 'underlay-color': bad, 'underlay-opacity': 0.35, 'underlay-padding': 7, 'underlay-shape': 'ellipse' } },
|
|
483
|
+
{ selector: 'node.withdrawn', style: { opacity: 0.22 } },
|
|
484
|
+
{ selector: 'node.superseded', style: { opacity: 0.5 } },
|
|
485
|
+
{ selector: 'node.leased', style: { 'underlay-color': '#4aa8ff', 'underlay-opacity': 0.35, 'underlay-padding': 6 } },
|
|
486
|
+
{ selector: 'node.revisit', style: { 'underlay-color': warn, 'underlay-opacity': 0.4, 'underlay-padding': 7 } },
|
|
487
|
+
{ selector: 'edge', style: {
|
|
488
|
+
width: 1.3, 'line-color': 'data(color)', 'target-arrow-color': 'data(color)', 'target-arrow-shape': 'data(arrow)',
|
|
489
|
+
'line-style': 'data(lstyle)', 'curve-style': 'bezier', 'arrow-scale': 0.7, opacity: 0.75 } },
|
|
490
|
+
{ selector: 'node:selected', style: { 'overlay-color': css('--a-accent', '#7cf3ff'), 'overlay-opacity': 0.2, 'overlay-padding': 6 } },
|
|
491
|
+
{ selector: '.faded', style: { opacity: 0.07 } },
|
|
492
|
+
{ selector: 'edge.path', style: { width: 2.6, opacity: 1 } },
|
|
493
|
+
{ selector: 'node.path', style: { opacity: 1 } },
|
|
494
|
+
{ selector: 'node.hit', style: { 'overlay-color': warn, 'overlay-opacity': 0.3, 'overlay-padding': 8 } },
|
|
495
|
+
];
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function elements() {
|
|
499
|
+
const vis = new Set(S.data.nodes.filter(visible).map((n) => n.id));
|
|
500
|
+
const nodes = S.data.nodes.filter((n) => vis.has(n.id)).map((n) => {
|
|
501
|
+
const t = TYPES[n.type] || TYPES.task;
|
|
502
|
+
const st = standing(n);
|
|
503
|
+
const cls = [st === 'withdrawn' ? 'withdrawn' : '', st === 'superseded' ? 'superseded' : '',
|
|
504
|
+
n.state && n.state.at_risk ? 'risk' : '', n.state && n.state.lease ? 'leased' : '',
|
|
505
|
+
n.state && n.state.basis_changed ? 'revisit' : ''].filter(Boolean).join(' ');
|
|
506
|
+
return { group: 'nodes', classes: cls, data: {
|
|
507
|
+
id: n.id, label: code(n), color: t.color, shape: t.shape, review: reviewState(n),
|
|
508
|
+
size: n.type === 'claim' && n.state ? t.size + Math.min(14, 3 * (n.state.independent_lines || 0)) : t.size } };
|
|
509
|
+
});
|
|
510
|
+
const edges = S.data.edges.filter((e) => vis.has(e.src) && vis.has(e.dst)).map((e) => {
|
|
511
|
+
const v = EDGES[e.rel] || EDGES.other;
|
|
512
|
+
return { group: 'edges', data: { id: 'e' + e.id, source: e.src, target: e.dst, rel: e.rel, color: v.color,
|
|
513
|
+
arrow: v.arrow, lstyle: v.style } };
|
|
514
|
+
});
|
|
515
|
+
return nodes.concat(edges);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function layoutOpts(name, n) {
|
|
519
|
+
const fcose = typeof global.cytoscapeFcose !== 'undefined';
|
|
520
|
+
if (name === 'fcose' && !fcose) name = 'cose';
|
|
521
|
+
if (name === 'fcose') return { name, quality: n > 700 ? 'draft' : 'default', randomize: true, animate: false,
|
|
522
|
+
nodeRepulsion: 7000, idealEdgeLength: 55, gravity: 0.3, nodeSeparation: 60, packComponents: true };
|
|
523
|
+
if (name === 'cose') return { name, animate: false, nodeRepulsion: 9000, idealEdgeLength: 60 };
|
|
524
|
+
if (name === 'breadthfirst') return { name, directed: true, spacingFactor: 0.9, animate: false,
|
|
525
|
+
roots: S.cy.nodes().filter((x) => x.indegree(false) === 0 && x.outdegree(false) > 0) };
|
|
526
|
+
return { name: 'concentric', concentric: (x) => x.degree(), levelWidth: () => 3, animate: false, minNodeSpacing: 8 };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
let pendingFocus = null;
|
|
530
|
+
function renderGraph() {
|
|
531
|
+
if (typeof global.cytoscape === 'undefined') {
|
|
532
|
+
$('#at-cy').innerHTML = '<div class="at-empty" style="padding:20px">Cytoscape is not loaded.</div>'; return;
|
|
533
|
+
}
|
|
534
|
+
if (typeof global.cytoscapeFcose !== 'undefined' && !mount._fcose) { global.cytoscape.use(global.cytoscapeFcose); mount._fcose = true; }
|
|
535
|
+
const els = elements();
|
|
536
|
+
if (!S.cy) {
|
|
537
|
+
S.cy = global.cytoscape({ container: $('#at-cy'), elements: els, style: cyStyle(), wheelSensitivity: 0.25,
|
|
538
|
+
minZoom: 0.05, maxZoom: 4, boxSelectionEnabled: false });
|
|
539
|
+
wireCy();
|
|
540
|
+
} else {
|
|
541
|
+
S.cy.elements().remove(); S.cy.add(els); S.cy.style(cyStyle());
|
|
542
|
+
}
|
|
543
|
+
S.cy.layout(layoutOpts($('#at-layout').value, els.length)).run();
|
|
544
|
+
S.cyStamp = graphStamp();
|
|
545
|
+
if (pendingFocus) { focusOn(pendingFocus); pendingFocus = null; }
|
|
546
|
+
else if (S.sel && S.cy.getElementById(S.sel).length) S.cy.getElementById(S.sel).select();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function wireCy() {
|
|
550
|
+
const tip = $('#at-tip');
|
|
551
|
+
S.cy.on('tap', 'node', (ev) => showInspector(ev.target.id()));
|
|
552
|
+
S.cy.on('dbltap', 'node', (ev) => focusOn(ev.target.id()));
|
|
553
|
+
S.cy.on('tap', (ev) => { if (ev.target === S.cy) clearFocus(); });
|
|
554
|
+
S.cy.on('mouseover', 'node', (ev) => {
|
|
555
|
+
const n = S.by.get(ev.target.id()); if (!n) return;
|
|
556
|
+
tip.innerHTML = `<b>${esc(code(n))}</b> ${esc(TYPES[n.type]?.label || n.type)}<div>${esc(n.title)}</div>
|
|
557
|
+
<div class="st">${esc((n.state || {}).sentence || '')}</div>`;
|
|
558
|
+
tip.hidden = false;
|
|
559
|
+
const p = ev.renderedPosition; tip.style.left = (p.x + 16) + 'px'; tip.style.top = (p.y + 10) + 'px';
|
|
560
|
+
});
|
|
561
|
+
S.cy.on('mouseout', 'node', () => { tip.hidden = true; });
|
|
562
|
+
S.cy.on('mouseover', 'edge', (ev) => {
|
|
563
|
+
const d = ev.target.data(); const e = S.data.edges.find((x) => 'e' + x.id === d.id);
|
|
564
|
+
tip.innerHTML = `<b>${esc(d.rel)}</b>${e && e.why ? `<div>${esc(e.why)}</div>` : ''}${e ? `<div class="st">by ${esc(e.by)} · ${esc(day(e.at))}</div>` : ''}`;
|
|
565
|
+
tip.hidden = false; const p = ev.renderedPosition || { x: 0, y: 0 };
|
|
566
|
+
tip.style.left = (p.x + 12) + 'px'; tip.style.top = (p.y + 8) + 'px';
|
|
567
|
+
});
|
|
568
|
+
S.cy.on('mouseout', 'edge', () => { tip.hidden = true; });
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function highlight(ids, edgeIds) {
|
|
572
|
+
if (!S.cy) return;
|
|
573
|
+
S.cy.batch(() => {
|
|
574
|
+
S.cy.elements().addClass('faded').removeClass('path');
|
|
575
|
+
ids.forEach((id) => S.cy.getElementById(id).removeClass('faded').addClass('path'));
|
|
576
|
+
(edgeIds || []).forEach((id) => S.cy.getElementById(id).removeClass('faded').addClass('path'));
|
|
577
|
+
if (!edgeIds) S.cy.edges().filter((e) => ids.has(e.source().id()) && ids.has(e.target().id())).removeClass('faded').addClass('path');
|
|
578
|
+
});
|
|
579
|
+
const col = S.cy.collection([...ids].map((id) => S.cy.getElementById(id)).filter((x) => x.length));
|
|
580
|
+
if (col.length) S.cy.animate({ fit: { eles: col, padding: 60 } }, { duration: 300 });
|
|
581
|
+
}
|
|
582
|
+
function clearFocus() { if (S.cy) S.cy.elements().removeClass('faded path hit'); }
|
|
583
|
+
|
|
584
|
+
function focusOn(id) {
|
|
585
|
+
if (!S.cy || !S.cy.getElementById(id).length) { pendingFocus = id; return; }
|
|
586
|
+
const depth = +$('#at-depth').value;
|
|
587
|
+
let col = S.cy.getElementById(id);
|
|
588
|
+
for (let i = 0; i < depth; i++) col = col.union(col.neighborhood());
|
|
589
|
+
highlight(new Set(col.nodes().map((x) => x.id())), null);
|
|
590
|
+
S.cy.getElementById(id).select();
|
|
591
|
+
}
|
|
592
|
+
async function whyOrImpact(kind) {
|
|
593
|
+
if (!S.sel) return;
|
|
594
|
+
try {
|
|
595
|
+
const r = await src[kind](S.sel);
|
|
596
|
+
const ids = new Set([S.sel, ...(r.nodes || []).map((n) => n.id), ...(r.dependents || []).map((d) => d.node.id)]);
|
|
597
|
+
if (S.view !== 'graph') setView('graph');
|
|
598
|
+
highlight(ids, (r.edges || []).map((e) => 'e' + e.id));
|
|
599
|
+
toast(kind === 'why' ? `${ids.size - 1} node(s) upstream — what it rests on` : `${ids.size - 1} node(s) rest on it`);
|
|
600
|
+
} catch (e) { toast(e.message); }
|
|
601
|
+
}
|
|
602
|
+
$('#at-depth').addEventListener('input', (ev) => { $('#at-depth-v').textContent = ev.target.value; if (S.sel) focusOn(S.sel); });
|
|
603
|
+
$('#at-fit').addEventListener('click', () => S.cy && S.cy.animate({ fit: { padding: 30 } }, { duration: 250 }));
|
|
604
|
+
$('#at-why').addEventListener('click', () => whyOrImpact('why'));
|
|
605
|
+
$('#at-impact').addEventListener('click', () => whyOrImpact('impact'));
|
|
606
|
+
$('#at-clear').addEventListener('click', clearFocus);
|
|
607
|
+
$('#at-layout').addEventListener('change', () => S.cy && S.cy.layout(layoutOpts($('#at-layout').value, S.cy.elements().length)).run());
|
|
608
|
+
|
|
609
|
+
// ── inspector ─────────────────────────────────────────────
|
|
610
|
+
function statClass(v) {
|
|
611
|
+
return { checked: 'st-good', contested: 'st-bad', needs_revalidation: 'st-warn', unchecked: 'st-dim',
|
|
612
|
+
withdrawn: 'st-dim', superseded: 'st-warn', active: 'st-good', published: 'st-good', archived: 'st-dim',
|
|
613
|
+
succeeded: 'st-good', failed: 'st-bad' }[v] || '';
|
|
614
|
+
}
|
|
615
|
+
const nodeLink = (id) => {
|
|
616
|
+
const n = S.by.get(id);
|
|
617
|
+
return `<button class="at-link" data-node="${esc(id)}" title="${esc(n ? n.title : id)}">${esc(n ? code(n) : id)}</button>`;
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
async function showInspector(id) {
|
|
621
|
+
if (!id) return;
|
|
622
|
+
S.sel = id;
|
|
623
|
+
['#at-why', '#at-impact'].forEach((s) => { $(s).disabled = false; });
|
|
624
|
+
const box = $('#at-insp');
|
|
625
|
+
let d;
|
|
626
|
+
try { d = await detail(id); } catch (e) { box.innerHTML = `<div class="at-empty">${esc(e.message)}</div>`; return; }
|
|
627
|
+
if (S.sel !== id) return;
|
|
628
|
+
const st = d.state || {};
|
|
629
|
+
const t = TYPES[d.type] || TYPES.task;
|
|
630
|
+
const dims = [];
|
|
631
|
+
dims.push(['lifecycle', st.lifecycle]);
|
|
632
|
+
if (st.review) dims.push(['review', st.review.state + (st.review.positive.length ? ' · ' + st.review.positive.join(', ') : '')
|
|
633
|
+
+ (st.review.open_objections.length ? ' · objection: ' + st.review.open_objections.map((o) => o.by).join(', ') : '')]);
|
|
634
|
+
if (st.execution) dims.push(['execution', st.execution]);
|
|
635
|
+
if (st.outcome) dims.push(['outcome', st.outcome]);
|
|
636
|
+
if (st.evidence) dims.push(['evidence', st.evidence.replace(/_/g, ' ')]);
|
|
637
|
+
if (st.independent_lines !== undefined) dims.push(['lines', String(st.independent_lines)]);
|
|
638
|
+
if (st.basis_changed) dims.push(['revisit', st.basis_changed.map((b) => `${nodeLink(b.node)} v${b.was_v}→v${b.now_v}`).join(' ')]);
|
|
639
|
+
if (st.at_risk) dims.push(['at risk', st.at_risk.map(nodeLink).join(' ')]);
|
|
640
|
+
if (st.rests_on_superseded) dims.push(['older premise', st.rests_on_superseded.map(nodeLink).join(' ')]);
|
|
641
|
+
if (st.lease) dims.push(['being worked', `${esc(st.lease.agent)} until ${esc(st.lease.expires.slice(0, 16).replace('T', ' '))}`]);
|
|
642
|
+
const fields = Object.entries(d.body || {}).filter(([k]) => k !== 'legacy').map(([k, v]) => {
|
|
643
|
+
const val = typeof v === 'string' ? esc(v) : `<pre>${esc(JSON.stringify(v, null, 1))}</pre>`;
|
|
644
|
+
const href = k === 'path' && typeof v === 'string' && opts.fileUrl ? opts.fileUrl(v) : '';
|
|
645
|
+
return `<div class="at-field"><span class="k">${esc(k)}</span>${val}${href ? ` <a class="at-link" target="_blank" rel="noopener" href="${esc(href)}">open</a>` : ''}</div>`;
|
|
646
|
+
}).join('');
|
|
647
|
+
const legacy = d.body && d.body.legacy ? `<div class="at-field"><span class="k">legacy record</span><pre>${esc(JSON.stringify(d.body.legacy, null, 1))}</pre></div>` : '';
|
|
648
|
+
const group = (list, dir) => {
|
|
649
|
+
const by = {};
|
|
650
|
+
list.forEach((e) => { (by[e.rel] = by[e.rel] || []).push(e); });
|
|
651
|
+
return Object.entries(by).map(([rel, es]) => `<div class="at-edge"><span class="rel">${dir === 'out' ? rel + ' →' : '← ' + rel}</span><span>${
|
|
652
|
+
es.slice(0, 30).map((e) => nodeLink(dir === 'out' ? e.dst : e.src)).join(' · ')}${es.length > 30 ? ` +${es.length - 30}` : ''}</span></div>`).join('');
|
|
653
|
+
};
|
|
654
|
+
const versions = (d.versions || []).map((v, i, arr) => {
|
|
655
|
+
const prev = i ? arr[i - 1].body : null;
|
|
656
|
+
const changed = prev ? Object.keys({ ...prev, ...v.body }).filter((k) => JSON.stringify(prev[k]) !== JSON.stringify(v.body[k])) : [];
|
|
657
|
+
return `<div class="at-ver">v${v.v} · ${esc(day(v.at))} · ${esc(v.by)} — ${esc(v.reason || 'created')}${
|
|
658
|
+
changed.length ? ` <span class="ch">changed: ${esc(changed.join(', '))}</span>` : ''}</div>`;
|
|
659
|
+
}).join('');
|
|
660
|
+
box.innerHTML = `
|
|
661
|
+
<div class="at-i-head"><span class="at-i-code">${esc(code(d))}</span>
|
|
662
|
+
<span class="at-badge" style="color:${t.color};border-color:${t.color}">${esc(t.label.toUpperCase())}${d.kind ? ' · ' + esc(d.kind) : ''}</span>
|
|
663
|
+
<span class="at-k">v${d.v} · ${esc(d.by)} · ${esc(day(d.at))}</span></div>
|
|
664
|
+
<div class="at-i-title">${esc(d.title)}</div>
|
|
665
|
+
<div class="at-sentence">${esc(st.sentence || '')}</div>
|
|
666
|
+
<dl class="at-dims">${dims.map(([k, v]) => `<dt>${esc(k)}</dt><dd class="${statClass(String(v).split(' ')[0])}">${String(v).includes('<button') ? v : esc(v)}</dd>`).join('')}</dl>
|
|
667
|
+
<div class="at-actions">
|
|
668
|
+
<button class="at-btn" data-act="focus">Focus</button><button class="at-btn" data-act="why">Why</button>
|
|
669
|
+
<button class="at-btn" data-act="impact">Impact</button><button class="at-btn" data-act="prov">Provenance</button>
|
|
670
|
+
<button class="at-btn" data-act="copy" title="Copy the node id">Copy id</button>
|
|
671
|
+
${OPERATOR ? `<a class="at-btn" href="${esc(OPERATOR)}" target="_blank" rel="noopener" title="Actions on the record live with the operator">Operator ↗</a>` : ''}
|
|
672
|
+
</div>
|
|
673
|
+
${d.withdrawn ? `<div class="at-sentence" style="border-color:var(--a-bad)">Withdrawn by ${esc(d.withdrawn.by)} ${esc(day(d.withdrawn.at))}: ${esc(d.withdrawn.why)}</div>` : ''}
|
|
674
|
+
<div class="at-i-sec"><h4>Fields</h4>${fields || '<div class="st-dim">—</div>'}${legacy}</div>
|
|
675
|
+
<div class="at-i-sec"><h4>Links out</h4>${group(d.out || [], 'out') || '<div class="st-dim">none</div>'}</div>
|
|
676
|
+
<div class="at-i-sec"><h4>Links in</h4>${group(d.in || [], 'in') || '<div class="st-dim">none</div>'}</div>
|
|
677
|
+
<div class="at-i-sec"><h4>Versions</h4>${versions || '<div class="st-dim">v1</div>'}</div>`;
|
|
678
|
+
if (S.cy && S.cy.getElementById(id).length) { S.cy.$(':selected').unselect(); S.cy.getElementById(id).select(); }
|
|
679
|
+
if (opts.hash !== false) history.replaceState(null, '', `#inv=${encodeURIComponent(S.inv)}&node=${encodeURIComponent(id)}`);
|
|
680
|
+
}
|
|
681
|
+
$('#at-insp').addEventListener('click', (ev) => {
|
|
682
|
+
const n = ev.target.closest('[data-node]');
|
|
683
|
+
if (n) { showInspector(n.dataset.node); if (S.view === 'graph') focusOn(n.dataset.node); return; }
|
|
684
|
+
const a = ev.target.closest('[data-act]'); if (!a) return;
|
|
685
|
+
const act = a.dataset.act;
|
|
686
|
+
if (act === 'focus') { if (S.view !== 'graph') setView('graph'); focusOn(S.sel); }
|
|
687
|
+
else if (act === 'why' || act === 'impact') whyOrImpact(act);
|
|
688
|
+
else if (act === 'prov') { S.provFor = S.sel; setView('provenance'); }
|
|
689
|
+
else if (act === 'copy') { navigator.clipboard?.writeText(S.sel).then(() => toast('Copied ' + S.sel)); }
|
|
690
|
+
});
|
|
691
|
+
root.addEventListener('click', (ev) => {
|
|
692
|
+
const n = ev.target.closest('.at-view [data-node]');
|
|
693
|
+
if (n) showInspector(n.dataset.node);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
// ── hypotheses ────────────────────────────────────────────
|
|
697
|
+
async function renderHypotheses() {
|
|
698
|
+
const box = $('#at-hyp');
|
|
699
|
+
const nodes = S.data.nodes;
|
|
700
|
+
const hyps = nodes.filter((n) => n.type === 'hypothesis');
|
|
701
|
+
const leads = nodes.filter((n) => n.type === 'claim' && n.kind === 'lead' && n.state.lifecycle === 'active');
|
|
702
|
+
const exps = nodes.filter((n) => n.type === 'experiment');
|
|
703
|
+
const qs = nodes.filter((n) => n.type === 'question');
|
|
704
|
+
if (!hyps.length && !leads.length && !qs.length) {
|
|
705
|
+
box.innerHTML = '<div class="at-empty">No hypotheses, questions or open leads in this investigation yet.</div>'; return;
|
|
706
|
+
}
|
|
707
|
+
box.innerHTML = '<div class="at-empty">Loading hypotheses…</div>';
|
|
708
|
+
const det = await Promise.all(hyps.concat(exps).map((n) => detail(n.id).catch(() => null)));
|
|
709
|
+
const D = new Map(det.filter(Boolean).map((x) => [x.id, x]));
|
|
710
|
+
const card = (n) => {
|
|
711
|
+
const d = D.get(n.id) || { body: {} };
|
|
712
|
+
const preds = (d.body.predictions || []).map((p) => `<li>${esc(p)}</li>`).join('');
|
|
713
|
+
return `<div class="at-card"><h5>${nodeLink(n.id)} ${esc(n.title)}</h5>
|
|
714
|
+
<div class="meta ${statClass(standing(n))}">${esc(n.state.sentence)}</div>${preds ? `<ul>${preds}</ul>` : ''}</div>`;
|
|
715
|
+
};
|
|
716
|
+
let html = '';
|
|
717
|
+
if (qs.length) html += `<div class="at-h2">Questions</div><div class="at-grid">${qs.map((q) =>
|
|
718
|
+
`<div class="at-card"><h5>${nodeLink(q.id)} ${esc(q.title)}</h5><div class="meta">${esc(q.state.sentence)}</div></div>`).join('')}</div>`;
|
|
719
|
+
if (hyps.length) html += `<div class="at-h2">Competing hypotheses (${hyps.length})</div><div class="at-grid">${hyps.map(card).join('')}</div>`;
|
|
720
|
+
const tests = S.data.edges.filter((e) => e.rel === 'tests');
|
|
721
|
+
if (exps.length && hyps.length) {
|
|
722
|
+
const outcome = (x) => {
|
|
723
|
+
const runs = S.data.edges.filter((e) => e.rel === 'executes' && e.dst === x).map((e) => e.src);
|
|
724
|
+
const res = S.data.edges.filter((e) => e.rel === 'produced' && runs.includes(e.src)).map((e) => S.by.get(e.dst)).filter(Boolean);
|
|
725
|
+
return res.length ? res.map((r) => `${code(r)}: ${r.state.outcome || '?'}`).join(', ') : (runs.length ? 'run, no result yet' : 'not run');
|
|
726
|
+
};
|
|
727
|
+
html += `<div class="at-h2">Experiments × hypotheses — expected outcome, then what happened</div>
|
|
728
|
+
<div class="at-table-wrap"><table class="at-table at-matrix"><thead><tr><th>Experiment</th>${hyps.map((h) => `<th>${esc(code(h))}</th>`).join('')}<th>Result</th></tr></thead><tbody>${
|
|
729
|
+
exps.map((x) => `<tr><td>${nodeLink(x.id)} ${esc(x.title.slice(0, 70))}</td>${hyps.map((h) => {
|
|
730
|
+
const t = tests.find((e) => e.src === x.id && e.dst === h.id);
|
|
731
|
+
return `<td class="cell">${t ? `<span class="exp">${esc(t.data.expected || '—')}</span>` : ''}</td>`;
|
|
732
|
+
}).join('')}<td class="cell"><span class="act">${esc(outcome(x.id))}</span></td></tr>`).join('')}</tbody></table></div>`;
|
|
733
|
+
}
|
|
734
|
+
if (leads.length) html += `<div class="at-h2">Open leads (${leads.length}) — angles worth testing, not evidence</div><div class="at-grid">${
|
|
735
|
+
leads.map((n) => `<div class="at-card"><h5>${nodeLink(n.id)} ${esc(n.title)}</h5><div class="meta">${esc(n.state.sentence)}</div></div>`).join('')}</div>`;
|
|
736
|
+
box.innerHTML = html;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ── claims ────────────────────────────────────────────────
|
|
740
|
+
const CLAIM_COLS = [
|
|
741
|
+
['code', 'Code'], ['kind', 'Kind'], ['title', 'Statement'], ['lifecycle', 'Lifecycle'], ['review', 'Review'],
|
|
742
|
+
['evidence', 'Evidence'], ['lines', 'Lines'], ['risk', 'Risk'], ['by', 'By'], ['at', 'Date'],
|
|
743
|
+
];
|
|
744
|
+
function claimRow(n) {
|
|
745
|
+
const st = n.state || {};
|
|
746
|
+
return { id: n.id, code: code(n), kind: n.kind || '', title: n.title, lifecycle: st.lifecycle, review: (st.review || {}).state || '',
|
|
747
|
+
evidence: (st.evidence || '').replace(/_/g, ' '), lines: st.independent_lines ?? '', risk: st.at_risk ? 'AT RISK' : st.rests_on_superseded ? 'older premise' : '',
|
|
748
|
+
by: n.by, at: day(n.at) };
|
|
749
|
+
}
|
|
750
|
+
function renderClaims() {
|
|
751
|
+
const q = ($('#at-claim-filter').value || '').toLowerCase();
|
|
752
|
+
const rows = S.data.nodes.filter((n) => n.type === 'claim').map(claimRow)
|
|
753
|
+
.filter((r) => !q || Object.values(r).join(' ').toLowerCase().includes(q));
|
|
754
|
+
const { k, asc } = S.sort;
|
|
755
|
+
const cmp = (x, y) => (typeof x === 'number' && typeof y === 'number' ? x - y
|
|
756
|
+
: String(x ?? '').localeCompare(String(y ?? ''), undefined, { numeric: true, sensitivity: 'base' })); // C2 before C10
|
|
757
|
+
rows.sort((a, b) => cmp(a[k], b[k]) * (asc ? 1 : -1));
|
|
758
|
+
$('#at-claim-count').textContent = `${rows.length} claim(s)`;
|
|
759
|
+
$('#at-claims').innerHTML = `<thead><tr>${CLAIM_COLS.map(([c, l]) => `<th data-k="${c}" class="${k === c ? 'sorted' + (asc ? ' asc' : '') : ''}">${l}</th>`).join('')}</tr></thead>
|
|
760
|
+
<tbody>${rows.map((r) => `<tr data-node="${esc(r.id)}"><td class="code">${esc(r.code)}</td><td>${esc(r.kind)}</td><td>${esc(r.title)}</td>
|
|
761
|
+
<td class="${statClass(r.lifecycle)}">${esc(r.lifecycle)}</td><td class="${statClass(r.review)}">${esc(r.review)}</td>
|
|
762
|
+
<td>${esc(r.evidence)}</td><td class="num">${esc(r.lines)}</td><td class="${r.risk === 'AT RISK' ? 'st-bad' : 'st-warn'}">${esc(r.risk)}</td>
|
|
763
|
+
<td>${esc(r.by)}</td><td>${esc(r.at)}</td></tr>`).join('')}</tbody>`;
|
|
764
|
+
}
|
|
765
|
+
$('#at-claims').addEventListener('click', (ev) => {
|
|
766
|
+
const th = ev.target.closest('th');
|
|
767
|
+
if (th) { const k = th.dataset.k; S.sort = { k, asc: S.sort.k === k ? !S.sort.asc : true }; renderClaims(); }
|
|
768
|
+
});
|
|
769
|
+
$('#at-claim-filter').addEventListener('input', () => renderClaims());
|
|
770
|
+
|
|
771
|
+
// ── provenance ────────────────────────────────────────────
|
|
772
|
+
async function renderProvenance() {
|
|
773
|
+
const box = $('#at-prov');
|
|
774
|
+
const id = S.provFor || S.sel;
|
|
775
|
+
const n = id && S.by.get(id);
|
|
776
|
+
if (!n || !['claim', 'result', 'run', 'hypothesis'].includes(n.type)) {
|
|
777
|
+
box.innerHTML = '<div class="at-empty">Select a claim, result, run or hypothesis (Inspector → Provenance) to trace it to the runs and exact files behind it.</div>';
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
box.innerHTML = '<div class="at-empty">Tracing…</div>';
|
|
781
|
+
let why;
|
|
782
|
+
try { why = await src.why(id); } catch (e) { box.innerHTML = `<div class="at-empty">${esc(e.message)}</div>`; return; }
|
|
783
|
+
const ids = why.nodes.map((x) => x.id);
|
|
784
|
+
const det = new Map((await Promise.all(ids.map((x) => detail(x).catch(() => null)))).filter(Boolean).map((x) => [x.id, x]));
|
|
785
|
+
const col = (type) => why.nodes.filter((x) => x.type === type && x.id !== id);
|
|
786
|
+
const runCard = (r) => {
|
|
787
|
+
const b = (det.get(r.id) || {}).body || {};
|
|
788
|
+
const hashes = (list) => (list || []).slice(0, 8).map((f) => `<div class="at-hash">${esc(f.path || '')} ${f.sha256 ? esc(f.sha256.slice(0, 16)) + '…' : ''}</div>`).join('');
|
|
789
|
+
return `<div class="at-card"><h5>${nodeLink(r.id)} ${esc(b.name || r.title)}</h5>
|
|
790
|
+
<div class="meta ${statClass(b.state)}">${esc(b.kind || '')} · ${esc(b.state || '')} · exit ${esc(b.exit_code ?? '–')} · seed ${esc(b.seed ?? '–')}</div>
|
|
791
|
+
${b.code && b.code.length ? `<div class="at-k" style="margin-top:6px">code</div>${hashes(b.code)}` : ''}
|
|
792
|
+
${b.inputs && b.inputs.length ? `<div class="at-k">inputs</div>${hashes(b.inputs)}` : ''}
|
|
793
|
+
${b.outputs && b.outputs.length ? `<div class="at-k">outputs</div>${hashes(b.outputs)}` : ''}
|
|
794
|
+
${b.note ? `<div class="meta" style="margin-top:6px">${esc(b.note)}</div>` : ''}</div>`;
|
|
795
|
+
};
|
|
796
|
+
const simple = (x) => `<div class="at-card"><h5>${nodeLink(x.id)} ${esc(x.title.slice(0, 110))}</h5><div class="meta">${esc(x.state.sentence)}</div></div>`;
|
|
797
|
+
const art = (x) => { const b = (det.get(x.id) || {}).body || {};
|
|
798
|
+
return `<div class="at-card"><h5>${nodeLink(x.id)}</h5><div class="at-hash">${esc(b.path || '')}</div><div class="at-hash">sha256 ${esc(b.sha256 || '')}</div></div>`; };
|
|
799
|
+
let groupsHtml = '';
|
|
800
|
+
if (n.type === 'claim' || n.type === 'hypothesis') {
|
|
801
|
+
const support = S.data.edges.filter((e) => e.rel === 'supports' && e.dst === id).map((e) => e.src);
|
|
802
|
+
const rootsOf = (x, seen = new Set()) => {
|
|
803
|
+
if (seen.has(x)) return new Set(); seen.add(x);
|
|
804
|
+
const out = new Set();
|
|
805
|
+
S.data.edges.forEach((e) => {
|
|
806
|
+
if (e.dst === x && e.rel === 'produced') { out.add(e.src); rootsOf(e.src, seen).forEach((y) => out.add(y)); }
|
|
807
|
+
if (e.src === x && (e.rel === 'uses' || e.rel === 'derived_from')) { out.add(e.dst); rootsOf(e.dst, seen).forEach((y) => out.add(y)); }
|
|
808
|
+
});
|
|
809
|
+
return out;
|
|
810
|
+
};
|
|
811
|
+
const groups = [];
|
|
812
|
+
support.forEach((s) => {
|
|
813
|
+
let r = rootsOf(s); if (!r.size) r = new Set(['(no provenance)']);
|
|
814
|
+
const hit = groups.filter((g) => [...g.roots].some((x) => r.has(x)));
|
|
815
|
+
const merged = { lines: [s], roots: r };
|
|
816
|
+
hit.forEach((g) => { merged.lines.push(...g.lines); g.roots.forEach((x) => merged.roots.add(x)); groups.splice(groups.indexOf(g), 1); });
|
|
817
|
+
groups.push(merged);
|
|
818
|
+
});
|
|
819
|
+
const colors = ['#4ee08a', '#4aa8ff', '#ffb547', '#b18cff', '#ff7ab6', '#2fe6d6'];
|
|
820
|
+
groupsHtml = `<div class="at-h2">Independent lines of support: ${groups.length}</div>${groups.map((g, i) =>
|
|
821
|
+
`<div class="at-group" style="border-color:${colors[i % colors.length]}"><div>${g.lines.map(nodeLink).join(' · ')}</div>
|
|
822
|
+
<div class="at-k">share: ${[...g.roots].slice(0, 6).map((x) => x === '(no provenance)' ? 'no recorded run or data' : nodeLink(x)).join(' ')}</div></div>`).join('')
|
|
823
|
+
|| '<div class="st-dim">No support linked.</div>'}`;
|
|
824
|
+
}
|
|
825
|
+
if (n.type === 'run') {
|
|
826
|
+
const made = S.data.edges.filter((e) => e.src === id && e.rel === 'produced').map((e) => S.by.get(e.dst)).filter(Boolean);
|
|
827
|
+
await Promise.all([id, ...made.map((m) => m.id)].map((x) => detail(x).then((d) => det.set(x, d)).catch(() => null)));
|
|
828
|
+
groupsHtml = `<div class="at-h2">What the run recorded</div>${runCard({ id, title: n.title })}
|
|
829
|
+
<div class="at-h2">What it produced</div><div class="at-chain">
|
|
830
|
+
<div class="at-col"><h6>Results</h6>${made.filter((m) => m.type === 'result').map(simple).join('') || '<div class="st-dim">—</div>'}</div>
|
|
831
|
+
<div class="at-col"><h6>Files (exact bytes)</h6>${made.filter((m) => m.type === 'artifact').map(art).join('') || '<div class="st-dim">—</div>'}</div></div>`;
|
|
832
|
+
}
|
|
833
|
+
box.innerHTML = `<div class="at-h2">${esc(code(n))} — ${esc(n.title.slice(0, 140))}</div>
|
|
834
|
+
<div class="at-sentence">${esc(n.state.sentence)}</div>${groupsHtml}
|
|
835
|
+
<div class="at-h2">The chain behind it</div>
|
|
836
|
+
<div class="at-chain">
|
|
837
|
+
<div class="at-col"><h6>Claims it rests on</h6>${col('claim').map(simple).join('') || '<div class="st-dim">—</div>'}</div>
|
|
838
|
+
<div class="at-col"><h6>Results</h6>${col('result').map(simple).join('') || '<div class="st-dim">—</div>'}</div>
|
|
839
|
+
<div class="at-col"><h6>Runs</h6>${col('run').map(runCard).join('') || '<div class="st-dim">—</div>'}</div>
|
|
840
|
+
<div class="at-col"><h6>Artifacts (exact bytes)</h6>${col('artifact').map(art).join('') || '<div class="st-dim">—</div>'}</div>
|
|
841
|
+
<div class="at-col"><h6>Sources</h6>${col('source').map(simple).join('') || '<div class="st-dim">—</div>'}</div>
|
|
842
|
+
</div>`;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// ── work ──────────────────────────────────────────────────
|
|
846
|
+
async function renderWork() {
|
|
847
|
+
const box = $('#at-work');
|
|
848
|
+
let w;
|
|
849
|
+
try { w = await src.work(S.inv); } catch (e) { box.innerHTML = `<div class="at-empty">${esc(e.message)}</div>`; return; }
|
|
850
|
+
let nxt = { ranked: [], untested_hypotheses: [] };
|
|
851
|
+
try { nxt = (await src.next(S.inv)) || nxt; } catch { /* none */ }
|
|
852
|
+
let cost = null;
|
|
853
|
+
try { cost = await src.cost(S.inv); } catch { /* no meter */ }
|
|
854
|
+
const list = (rows, empty) => rows.length ? rows.slice(0, 60).map((n) =>
|
|
855
|
+
`<div class="at-edge"><span class="rel">${esc(n.kind || n.type)}</span><span>${nodeLink(n.id)} ${esc(n.title.slice(0, 90))}</span></div>`).join('') : `<div class="st-dim">${empty}</div>`;
|
|
856
|
+
const authors = {};
|
|
857
|
+
S.data.nodes.forEach((n) => { if (n.type !== 'artifact' && n.type !== 'review') authors[n.by] = (authors[n.by] || 0) + 1; });
|
|
858
|
+
const reviewers = {};
|
|
859
|
+
S.data.nodes.filter((n) => n.type === 'review').forEach((n) => { reviewers[n.by] = (reviewers[n.by] || 0) + 1; });
|
|
860
|
+
const bars = (obj) => { const max = Math.max(1, ...Object.values(obj));
|
|
861
|
+
return Object.entries(obj).sort((a, b) => b[1] - a[1]).slice(0, 14).map(([k, v]) =>
|
|
862
|
+
`<div class="at-bar-row"><span>${esc(k)}</span><span class="bar" style="width:${(100 * v / max).toFixed(1)}%"></span><span class="num">${v}</span></div>`).join(''); };
|
|
863
|
+
box.innerHTML = `<div class="at-work-grid">
|
|
864
|
+
<div class="at-card"><h5>Being worked now (${w.leases.length})</h5>${w.leases.length ? w.leases.map((L) =>
|
|
865
|
+
`<div class="at-edge"><span class="rel">${esc(L.agent)}</span><span>${nodeLink(L.node.id)} ${esc(L.node.title.slice(0, 70))}</span></div>`).join('') : '<div class="st-dim">Nobody holds a lease.</div>'}</div>
|
|
866
|
+
<div class="at-card"><h5>Next experiments</h5>${nxt.ranked.length ? nxt.ranked.map((r) =>
|
|
867
|
+
`<div class="at-edge"><span class="rel">score ${r.score}</span><span>${nodeLink(r.experiment.id)} separates ${r.separates}${r.power_check ? ', powered' : ''}${r.duplicate ? ', DUPLICATE' : ''}</span></div>`).join('') : '<div class="st-dim">None planned.</div>'}
|
|
868
|
+
${nxt.untested_hypotheses.length ? `<div class="at-k" style="margin-top:6px">untested hypotheses</div>${list(nxt.untested_hypotheses, '')}` : ''}</div>
|
|
869
|
+
<div class="at-card"><h5>Review queue (${w.review_queue.length})</h5>${list(w.review_queue, 'Nothing waiting.')}</div>
|
|
870
|
+
<div class="at-card"><h5>Needs revalidation (${w.revalidation.length})</h5>${list(w.revalidation, 'Nothing moved under anyone.')}</div>
|
|
871
|
+
<div class="at-card"><h5>Contested (${w.contested.length})</h5>${list(w.contested, 'No open disputes.')}</div>
|
|
872
|
+
<div class="at-card"><h5>Decisions to revisit (${(w.decisions_to_revisit || []).length})</h5>${list(w.decisions_to_revisit || [], 'None — every decision still rests on what it saw.')}</div>
|
|
873
|
+
<div class="at-card"><h5>Duplicate work (${w.duplicates.length})</h5>${w.duplicates.length ? w.duplicates.map((g) => `<div>${g.map((n) => nodeLink(n.id)).join(' = ')}</div>`).join('') : '<div class="st-dim">No duplicates found.</div>'}</div>
|
|
874
|
+
${cost && cost.metered !== false && cost.usd !== undefined ? spendCard(cost) : ''}
|
|
875
|
+
<div class="at-card"><h5>Who wrote the record</h5>${bars(authors)}</div>
|
|
876
|
+
<div class="at-card"><h5>Who reviewed it</h5>${Object.keys(reviewers).length ? bars(reviewers) : '<div class="st-dim">No reviews yet.</div>'}</div>
|
|
877
|
+
</div>`;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Spend: from the host's meter (Graph(spend=…)); nobody writes it.
|
|
881
|
+
function spendCard(c) {
|
|
882
|
+
const usd = (v) => '$' + Number(v || 0).toFixed(v >= 10 ? 2 : 3);
|
|
883
|
+
const tok = (v) => v >= 1e6 ? (v / 1e6).toFixed(1) + 'M' : v >= 1e3 ? Math.round(v / 1e3) + 'k' : String(v || 0);
|
|
884
|
+
const rows = (list, key) => { const max = Math.max(1e-9, ...list.map((r) => r.usd || 0));
|
|
885
|
+
return list.slice(0, 10).map((r) => `<div class="at-bar-row"><span>${esc(String(r[key] || '—').split('/').pop())}</span>`
|
|
886
|
+
+ `<span class="bar" style="width:${(100 * (r.usd || 0) / max).toFixed(1)}%"></span><span class="num">${usd(r.usd)}</span></div>`).join(''); };
|
|
887
|
+
const cached = c.input_tokens ? Math.round(100 * c.cached_tokens / c.input_tokens) : 0;
|
|
888
|
+
const days = (c.by_day || []).slice(-14);
|
|
889
|
+
const dmax = Math.max(1e-9, ...days.map((d) => d.usd || 0));
|
|
890
|
+
return `<div class="at-card"><h5>Spend ${usd(c.usd)}</h5>
|
|
891
|
+
<div class="at-k">${c.calls} model calls · ${tok(c.input_tokens)} in (${cached}% cached) · ${tok(c.output_tokens)} out
|
|
892
|
+
· ${c.tasks} task(s)${c.unpriced_calls ? ` · ${c.unpriced_calls} unpriced` : ''}</div>
|
|
893
|
+
<div class="at-k">${c.reviews} review verdict(s)${c.usd_per_verdict != null ? ' · ' + usd(c.usd_per_verdict) + ' per verdict' : ''}
|
|
894
|
+
· lab: ${c.lab.runs} run(s), ${c.lab.minutes} min (local)</div>
|
|
895
|
+
<div class="at-k">since the record last moved${c.last_moved ? ' (' + esc(c.last_moved.slice(0, 10)) + ')' : ''}: ${usd(c.since_last_moved.usd)}</div>
|
|
896
|
+
${days.length ? `<div class="at-spark" title="spend per day">${days.map((d) =>
|
|
897
|
+
`<span title="${esc(d.day)}: ${usd(d.usd)}" style="height:${Math.max(4, 100 * (d.usd || 0) / dmax).toFixed(0)}%"></span>`).join('')}</div>` : ''}
|
|
898
|
+
${(c.by_agent || []).length ? `<div class="at-k" style="margin-top:6px">by teammate</div>${rows(c.by_agent, 'agent_id')}` : ''}
|
|
899
|
+
${(c.by_model || []).length ? `<div class="at-k" style="margin-top:6px">by model</div>${rows(c.by_model, 'model')}` : ''}
|
|
900
|
+
${(c.top_nodes || []).length ? `<div class="at-k" style="margin-top:6px">costliest nodes (shared work — overlapping)</div>`
|
|
901
|
+
+ c.top_nodes.slice(0, 6).map((n) => `<div class="at-edge"><span class="rel">${usd(n.usd)}</span><span>${nodeLink(n.id)} ${esc(n.title.slice(0, 70))}</span></div>`).join('') : ''}
|
|
902
|
+
</div>`;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// ── timeline ──────────────────────────────────────────────
|
|
906
|
+
async function loadEvents() {
|
|
907
|
+
const r = await src.events(0, S.inv, 5000);
|
|
908
|
+
S.events = r.events || []; S.head = Math.max(S.head, r.head || 0);
|
|
909
|
+
}
|
|
910
|
+
async function renderTimeline() {
|
|
911
|
+
if (!S.events.length) { try { await loadEvents(); } catch (e) { $('#at-tl-feed').innerHTML = `<div class="at-empty">${esc(e.message)}</div>`; return; } }
|
|
912
|
+
const days = {};
|
|
913
|
+
S.events.forEach((e) => { const d = day(e.at); (days[d] = days[d] || {})[e.kind] = ((days[d] || {})[e.kind] || 0) + 1; });
|
|
914
|
+
const keys = Object.keys(days).sort();
|
|
915
|
+
const svg = $('#at-tl-chart');
|
|
916
|
+
const W = svg.clientWidth || 800, H = 150, pad = 24;
|
|
917
|
+
const kinds = ['create', 'link', 'review', 'revise', 'withdraw', 'retract', 'lease_take', 'lease_release'];
|
|
918
|
+
const colors = { create: '#4ee08a', link: '#4aa8ff', review: '#ffd166', revise: '#ffb547', withdraw: '#ff4d5e', retract: '#94a3b8', lease_take: '#b18cff', lease_release: '#7c8ea3' };
|
|
919
|
+
const max = Math.max(1, ...keys.map((k) => Object.values(days[k]).reduce((a, b) => a + b, 0)));
|
|
920
|
+
const bw = Math.max(4, (W - 2 * pad) / Math.max(1, keys.length) - 4);
|
|
921
|
+
let out = '';
|
|
922
|
+
keys.forEach((k, i) => {
|
|
923
|
+
let y = H - pad; const x = pad + i * (bw + 4);
|
|
924
|
+
kinds.forEach((kd) => { const v = days[k][kd] || 0; if (!v) return; const h = (H - 2 * pad) * v / max; y -= h;
|
|
925
|
+
out += `<rect x="${x}" y="${y}" width="${bw}" height="${h}" fill="${colors[kd]}"><title>${k} · ${kd}: ${v}</title></rect>`; });
|
|
926
|
+
if (keys.length <= 20 || i % Math.ceil(keys.length / 12) === 0) out += `<text x="${x}" y="${H - 6}" font-size="9" fill="currentColor">${k.length > 7 && keys[0].slice(0, 4) === keys[keys.length - 1].slice(0, 4) ? k.slice(5) : k}</text>`;
|
|
927
|
+
});
|
|
928
|
+
svg.setAttribute('viewBox', `0 0 ${W} ${H}`); svg.style.color = 'var(--a-ink2)'; svg.innerHTML = out;
|
|
929
|
+
const range = $('#at-tl-range');
|
|
930
|
+
range.max = Math.max(0, S.events.length - 1); if (S.until === null) range.value = range.max;
|
|
931
|
+
const feed = S.events.slice(-300).reverse();
|
|
932
|
+
$('#at-tl-feed').innerHTML = feed.map((e) => {
|
|
933
|
+
const n = S.by.get(e.ref);
|
|
934
|
+
return `<div class="ev"><span class="t">${esc(e.at.replace('T', ' ').slice(0, 19))}</span><span>${esc(e.actor)}</span><span>${esc(e.kind)}</span>
|
|
935
|
+
<span>${n ? nodeLink(n.id) + ' ' + esc(n.title.slice(0, 80)) : esc(e.ref)}${e.payload && e.payload.verdict ? ' — ' + esc(e.payload.verdict) : ''}${e.payload && e.payload.rel ? ' — ' + esc(e.payload.rel) : ''}</span></div>`;
|
|
936
|
+
}).join('') || '<div class="at-empty">No events.</div>';
|
|
937
|
+
updateUntilLabel();
|
|
938
|
+
}
|
|
939
|
+
function updateUntilLabel() {
|
|
940
|
+
const i = +$('#at-tl-range').value; const e = S.events[i];
|
|
941
|
+
$('#at-tl-at').textContent = e ? `graph as of ${e.at.replace('T', ' ').slice(0, 16)} (event ${i + 1}/${S.events.length})` : '';
|
|
942
|
+
}
|
|
943
|
+
$('#at-tl-range').addEventListener('input', (ev) => {
|
|
944
|
+
const i = +ev.target.value; const e = S.events[i];
|
|
945
|
+
S.until = (i >= S.events.length - 1) ? null : (e ? e.at : null);
|
|
946
|
+
updateUntilLabel();
|
|
947
|
+
});
|
|
948
|
+
$('#at-tl-range').addEventListener('change', () => toast(S.until ? 'Graph view now shows the record as of that point — open Graph to see it.' : 'Showing the whole record.'));
|
|
949
|
+
$('#at-tl-play').addEventListener('click', async () => {
|
|
950
|
+
if (!S.events.length) await loadEvents();
|
|
951
|
+
setView('graph');
|
|
952
|
+
const times = [...new Set(S.data.nodes.map((n) => n.at).sort())];
|
|
953
|
+
const steps = 40; let i = 0;
|
|
954
|
+
const tick = () => {
|
|
955
|
+
i += 1; S.until = times[Math.min(times.length - 1, Math.floor(times.length * i / steps))];
|
|
956
|
+
if (S.cy) S.cy.batch(() => S.cy.nodes().forEach((x) => { const n = S.by.get(x.id()); x.style('display', n && n.at <= S.until ? 'element' : 'none'); }));
|
|
957
|
+
if (i < steps) setTimeout(tick, 120); else { S.until = null; S.cy && S.cy.nodes().style('display', 'element'); toast('Replay done'); }
|
|
958
|
+
};
|
|
959
|
+
tick();
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
// ── search ────────────────────────────────────────────────
|
|
963
|
+
const qbox = $('#at-q'); const results = $('#at-results');
|
|
964
|
+
qbox.addEventListener('input', () => {
|
|
965
|
+
const q = qbox.value.trim().toLowerCase();
|
|
966
|
+
if (!q) { results.hidden = true; clearFocus(); return; }
|
|
967
|
+
const hits = S.data.nodes.filter((n) => code(n).toLowerCase() === q || code(n).toLowerCase().startsWith(q) || (n.title || '').toLowerCase().includes(q)).slice(0, 30);
|
|
968
|
+
results.innerHTML = hits.map((n) => `<button data-node="${esc(n.id)}"><b>${esc(code(n))}</b> ${esc((TYPES[n.type] || {}).label || n.type)} — ${esc(n.title.slice(0, 100))}</button>`).join('') || '<div class="at-empty" style="padding:8px">No match in this investigation.</div>';
|
|
969
|
+
results.hidden = false;
|
|
970
|
+
if (S.cy) { S.cy.nodes().removeClass('hit'); hits.forEach((n) => S.cy.getElementById(n.id).addClass('hit')); }
|
|
971
|
+
});
|
|
972
|
+
results.addEventListener('click', (ev) => {
|
|
973
|
+
const b = ev.target.closest('[data-node]'); if (!b) return;
|
|
974
|
+
results.hidden = true; showInspector(b.dataset.node);
|
|
975
|
+
if (S.view !== 'graph') setView('graph'); focusOn(b.dataset.node);
|
|
976
|
+
});
|
|
977
|
+
document.addEventListener('click', (ev) => { if (!ev.target.closest('.at-search')) results.hidden = true; });
|
|
978
|
+
|
|
979
|
+
// ── toolbar ───────────────────────────────────────────────
|
|
980
|
+
$('#at-inv').addEventListener('change', (ev) => loadInv(ev.target.value));
|
|
981
|
+
$('#at-theme').addEventListener('click', () => {
|
|
982
|
+
root.dataset.theme = root.dataset.theme === 'light' ? 'dark' : 'light';
|
|
983
|
+
try { localStorage.setItem('atlas-theme', root.dataset.theme); } catch { /* private mode */ }
|
|
984
|
+
if (S.cy) S.cy.style(cyStyle());
|
|
985
|
+
if (opts.onTheme) opts.onTheme(root.dataset.theme);
|
|
986
|
+
});
|
|
987
|
+
try { const t = localStorage.getItem('atlas-theme'); if (t && !opts.theme) root.dataset.theme = t; } catch { /* private mode */ }
|
|
988
|
+
$('#at-export').addEventListener('click', () => {
|
|
989
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
990
|
+
if (S.cy) dl(S.cy.png({ full: true, scale: 2, bg: css('--a-bg', '#0a0d14') }), `atlas-${S.inv}-${stamp}.png`);
|
|
991
|
+
const data = src.kind === 'bundle' ? src.bundle : S.data;
|
|
992
|
+
dl(URL.createObjectURL(new Blob([JSON.stringify(data, null, 1)], { type: 'application/json' })),
|
|
993
|
+
src.kind === 'bundle' ? `hypgraph-${S.inv}-${stamp}.json` : `atlas-${S.inv}-${stamp}.json`);
|
|
994
|
+
});
|
|
995
|
+
function dl(href, name) { const a = document.createElement('a'); a.href = href; a.download = name; document.body.appendChild(a); a.click(); a.remove(); }
|
|
996
|
+
|
|
997
|
+
// ── keyboard ──────────────────────────────────────────────
|
|
998
|
+
if (opts.hash !== false) {
|
|
999
|
+
global.addEventListener('hashchange', () => {
|
|
1000
|
+
const h = new URLSearchParams(location.hash.slice(1));
|
|
1001
|
+
const inv = h.get('inv'), node = h.get('node');
|
|
1002
|
+
if (inv && inv !== S.inv) {
|
|
1003
|
+
if (!S.invs.some((i) => i.id === inv)) { loadInvestigations(); return; }
|
|
1004
|
+
$('#at-inv').value = inv; loadInv(inv, node); return;
|
|
1005
|
+
}
|
|
1006
|
+
if (node && node !== S.sel) { showInspector(node); if (S.view === 'graph') focusOn(node); }
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
document.addEventListener('keydown', (ev) => {
|
|
1010
|
+
if (!root.isConnected || root.offsetParent === null) return;
|
|
1011
|
+
if (ev.target.matches('input, select, textarea')) { if (ev.key === 'Escape') ev.target.blur(); return; }
|
|
1012
|
+
if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
|
|
1013
|
+
const views = ['graph', 'hypotheses', 'claims', 'provenance', 'work', 'timeline'];
|
|
1014
|
+
if (ev.key === '/') { ev.preventDefault(); qbox.focus(); }
|
|
1015
|
+
else if (ev.key === 'Escape') { clearFocus(); results.hidden = true; }
|
|
1016
|
+
else if (/^[1-6]$/.test(ev.key)) setView(views[+ev.key - 1]);
|
|
1017
|
+
else if (ev.key === 'f' && S.sel) focusOn(S.sel);
|
|
1018
|
+
else if (ev.key === 'w' && S.sel) whyOrImpact('why');
|
|
1019
|
+
else if (ev.key === 'i' && S.sel) whyOrImpact('impact');
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
// ── live: poll the event log ──────────────────────────────
|
|
1023
|
+
let reloadTimer = null;
|
|
1024
|
+
async function poll() {
|
|
1025
|
+
try {
|
|
1026
|
+
const r = await src.events(S.head, '', 500);
|
|
1027
|
+
$('#at-live').classList.remove('off');
|
|
1028
|
+
const mine = (r.events || []).filter((e) => !S.inv || S.by.has(e.ref) || e.ref === S.inv || (e.payload && e.payload.inv === S.inv));
|
|
1029
|
+
if (r.events && r.events.length) S.head = r.head;
|
|
1030
|
+
if (mine.length) {
|
|
1031
|
+
toast(`${mine.length} new change(s) in this investigation`);
|
|
1032
|
+
clearTimeout(reloadTimer);
|
|
1033
|
+
reloadTimer = setTimeout(async () => {
|
|
1034
|
+
const keep = S.sel;
|
|
1035
|
+
S.data = await src.subgraph(S.inv);
|
|
1036
|
+
S.by = new Map(S.data.nodes.map((n) => [n.id, n])); S.details.clear(); S.events = [];
|
|
1037
|
+
renderRail(); render(); if (keep) showInspector(keep);
|
|
1038
|
+
}, 800);
|
|
1039
|
+
}
|
|
1040
|
+
} catch { const l = $('#at-live'); if (l) l.classList.add('off'); }
|
|
1041
|
+
setTimeout(poll, opts.pollMs || 15000);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const ready = loadInvestigations().then(() => { if (live) setTimeout(poll, opts.pollMs || 15000); }).catch((e) => {
|
|
1045
|
+
$('#at-insp').innerHTML = `<div class="at-empty">Couldn't reach the research graph: ${esc(e.message)}</div>`;
|
|
1046
|
+
});
|
|
1047
|
+
const setTheme = (t) => { root.dataset.theme = t; if (S.cy) S.cy.style(cyStyle()); };
|
|
1048
|
+
return { ready, state: S, show: showInspector, setView, focus: focusOn, setTheme, source: src };
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
global.HypAtlas = { mount, HttpSource, BundleSource, verifyBundle, sha256, TYPES, EDGES, version: VERSION };
|
|
1052
|
+
})(typeof window !== 'undefined' ? window : globalThis);
|