create-pmos 0.65.0 → 0.65.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/docs.md +59 -6
- package/workspace/guide/initiatives-screen.md +8 -0
- package/workspace/lib/doc-library.js +131 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +307 -0
- package/workspace/public/pm/dest-docs.js +144 -44
- package/workspace/public/pm/doc-graph.js +272 -0
- package/workspace/public/pm/initiative-sheet.js +24 -2
- package/workspace/public/pm/initiatives-copy.js +8 -0
- package/workspace/server.js +21 -0
- package/workspace/test-lists/docs-library-graph.json +5 -0
|
@@ -6,13 +6,17 @@
|
|
|
6
6
|
* only the body is rebuilt when its own data changed.
|
|
7
7
|
*
|
|
8
8
|
* What it shows, for the repository the tab names (or the open one):
|
|
9
|
-
* - Docs, a search box, Needs you · All, and when the copies were last compared, with a control
|
|
10
|
-
*
|
|
9
|
+
* - Docs, a search box, Needs you · With a copy · All files, and when the copies were last compared, with a control
|
|
10
|
+
* that compares again;
|
|
11
|
+
* - the copies grouped by category, ONE line per copy: a status dot, the title, a link that opens the copy, one
|
|
11
12
|
* sentence (which side is newer, and who edited it last) and at most one button (Use PMOS version, Use Confluence
|
|
12
13
|
* version, or Choose… when both changed);
|
|
13
14
|
* - a click on a line: both sides with where they live and their last edit, a Use … version button for each, the first
|
|
14
|
-
* difference the check saw, Compare again
|
|
15
|
-
* -
|
|
15
|
+
* difference the check saw, Compare again, Stop tracking this copy, and the file's links (doc-graph.js);
|
|
16
|
+
* - All files (docs-library-graph): every repository file under folding categories, each line naming its initiative;
|
|
17
|
+
* a category opens on its first 20 files. A press on a file shows the files it links to and the files that link to
|
|
18
|
+
* it. Add copy shows on hover or focus, and always on a touch screen;
|
|
19
|
+
* - search: every file whose title, path or initiative matches, grouped by category, with the match marked.
|
|
16
20
|
* The signal is decided on the server (lib/doc-sync.js) from what a check session read. Nothing here reads or writes
|
|
17
21
|
* Google Docs or Confluence. Use … version waits five seconds behind Undo, then asks the server to start ONE doc-sync
|
|
18
22
|
* session for that copy, which writes one side with the PM's own connectors (the PM's ruling, amended 2026-10-06).
|
|
@@ -21,13 +25,20 @@
|
|
|
21
25
|
|
|
22
26
|
import { el } from './dom.js';
|
|
23
27
|
import { button, row, field, toast, empty, tabs, ensureComponentsCss } from './primitives.js';
|
|
24
|
-
import { ensureSprite } from './icons.js';
|
|
28
|
+
import { ensureSprite, icon } from './icons.js';
|
|
25
29
|
import { pmGet, pmPost } from './http.js';
|
|
26
30
|
import { wsOf } from './data.js';
|
|
27
31
|
import { registerCommand, openDestination } from './registry.js';
|
|
32
|
+
import { getLibrary, fileGraph } from './doc-graph.js';
|
|
33
|
+
import { openDoc } from './panel-initiatives.js';
|
|
28
34
|
|
|
29
35
|
const LABEL = { google: 'Google Docs', confluence: 'Confluence' };
|
|
30
36
|
const UNDO_MS = 5000;
|
|
37
|
+
const PAGE = 20;
|
|
38
|
+
/* the library's categories, in display order (lib/doc-library.js CATEGORIES) */
|
|
39
|
+
const CATS = [['spec', 'Specs'], ['ini', 'Initiatives'], ['dec', 'Decisions'], ['res', 'Research & plans'], ['exp', 'Explainers'],
|
|
40
|
+
['eval', 'Evals'], ['know', 'Knowledge'], ['guide', 'Guides & rules'], ['proto', 'Prototypes']];
|
|
41
|
+
const CAT_NAME = Object.fromEntries(CATS);
|
|
31
42
|
|
|
32
43
|
export const COPY = Object.freeze({
|
|
33
44
|
title: 'Docs',
|
|
@@ -36,20 +47,25 @@ export const COPY = Object.freeze({
|
|
|
36
47
|
loading: 'Reading…',
|
|
37
48
|
readError: 'The documents could not be read.',
|
|
38
49
|
find: 'Find a document',
|
|
50
|
+
findIn: (n) => `Find in ${n.toLocaleString('en-US')} files`,
|
|
39
51
|
needs: 'Needs you',
|
|
40
|
-
|
|
52
|
+
copies: 'With a copy',
|
|
53
|
+
files: 'All files',
|
|
54
|
+
readingFiles: 'Reading the files…',
|
|
55
|
+
filesError: 'The file list could not be read.',
|
|
56
|
+
matches: (n, all) => `${n} of ${all.toLocaleString('en-US')} files match by title, path or initiative. Press Esc to clear.`,
|
|
57
|
+
showAll: (n, cat) => `Show all ${n} in ${cat}`,
|
|
58
|
+
needYou: (n) => `${n} need${n === 1 ? 's' : ''} you`,
|
|
41
59
|
compared: (iso) => 'Compared ' + ago(iso),
|
|
42
60
|
never: 'Not compared yet',
|
|
43
61
|
comparing: 'Comparing…',
|
|
44
62
|
compareAll: 'Compare every copy again',
|
|
45
|
-
none: 'No document has a copy yet.
|
|
63
|
+
none: 'No document has a copy yet. Open All files and press Add copy on a file.',
|
|
46
64
|
nothingNeeds: 'Nothing needs you. Every copy matches PMOS.',
|
|
47
65
|
noMatch: (q) => `No document matches “${q}”.`,
|
|
48
|
-
other: 'Other',
|
|
49
66
|
usePmos: 'Use PMOS version',
|
|
50
67
|
useCopy: (label) => `Use ${label} version`,
|
|
51
68
|
choose: 'Choose…',
|
|
52
|
-
noCopy: 'No copy',
|
|
53
69
|
addCopy: 'Add copy',
|
|
54
70
|
add: 'Add',
|
|
55
71
|
cancel: 'Cancel',
|
|
@@ -91,6 +107,23 @@ function when(iso) {
|
|
|
91
107
|
if (days > 1 && days < 7) return t.toLocaleDateString('en-US', { weekday: 'short' }) + ' ' + hm;
|
|
92
108
|
return t.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
93
109
|
}
|
|
110
|
+
/** A file's last edit from its YYYY-MM-DD date: "Oct 6", or "Oct 6, 2025" in another year. */
|
|
111
|
+
function day(d) {
|
|
112
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(d || ''));
|
|
113
|
+
if (!m) return '';
|
|
114
|
+
const t = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
115
|
+
return t.toLocaleDateString('en-US', { month: 'short', day: 'numeric', ...(t.getFullYear() !== new Date().getFullYear() ? { year: 'numeric' } : {}) });
|
|
116
|
+
}
|
|
117
|
+
/** The text with every match of q marked. */
|
|
118
|
+
function marked(text, q) {
|
|
119
|
+
const t = String(text || '');
|
|
120
|
+
if (!q) return t;
|
|
121
|
+
const out = []; const low = t.toLowerCase(); let at = 0;
|
|
122
|
+
for (let k = low.indexOf(q); k >= 0 && q; k = low.indexOf(q, at)) { out.push(t.slice(at, k), el('mark', { class: 'rp-docs-mark' }, t.slice(k, k + q.length))); at = k + q.length; }
|
|
123
|
+
out.push(t.slice(at));
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
const openFile = (p) => openDoc(null, p, 'FILE');
|
|
94
127
|
const edit = (by, at) => [by, at ? when(at) : null].filter(Boolean).join(', ');
|
|
95
128
|
const keyOf = (doc, link) => `${doc.path}|${link.kind}`;
|
|
96
129
|
/** Which kind a pasted address names. The server checks it again; this only picks the kind. */
|
|
@@ -134,7 +167,8 @@ export function mountDestination(host, ctx) {
|
|
|
134
167
|
if (!inst) {
|
|
135
168
|
inst = { host, root: el('div', { class: 'rp-page rp-docs', 'data-docs': '' }), head: null, body: el('div', { class: 'rp-docs-body' }),
|
|
136
169
|
ws: null, loaded: false, error: null, view: null, filter: 'needs', q: '', files: [], open: null, adding: null,
|
|
137
|
-
pending: new Map(), busy: false, seq: 0, fseq: 0, last: '', timer: null, ftimer: null
|
|
170
|
+
pending: new Map(), busy: false, seq: 0, fseq: 0, last: '', timer: null, ftimer: null,
|
|
171
|
+
lib: null, libError: null, openCats: new Set(), full: new Set(), graph: null };
|
|
138
172
|
insts.set(host, inst);
|
|
139
173
|
}
|
|
140
174
|
ensureComponentsCss();
|
|
@@ -144,7 +178,8 @@ export function mountDestination(host, ctx) {
|
|
|
144
178
|
const ws = repo ? wsOf(repo) : null;
|
|
145
179
|
if (ws !== inst.ws) {
|
|
146
180
|
stopPoll(inst);
|
|
147
|
-
Object.assign(inst, { ws, loaded: false, error: null, view: null, last: '', open: null, adding: null, files: [] });
|
|
181
|
+
Object.assign(inst, { ws, loaded: false, error: null, view: null, last: '', open: null, adding: null, files: [], lib: null, libError: null, graph: null });
|
|
182
|
+
inst.openCats.clear(); inst.full.clear();
|
|
148
183
|
if (ws) { ensureSprite(); refresh(inst); }
|
|
149
184
|
draw(inst);
|
|
150
185
|
return;
|
|
@@ -177,8 +212,9 @@ async function refresh(inst) {
|
|
|
177
212
|
if (!ws) return;
|
|
178
213
|
const seq = ++inst.seq;
|
|
179
214
|
inst.readAt = Date.now();
|
|
180
|
-
const v = await pmGet('/api/docs?ws=' + encodeURIComponent(ws));
|
|
215
|
+
const [v, lib] = await Promise.all([pmGet('/api/docs?ws=' + encodeURIComponent(ws)), getLibrary(ws)]);
|
|
181
216
|
if (seq !== inst.seq || ws !== inst.ws) return;
|
|
217
|
+
if (lib) { inst.lib = lib; inst.libError = null; } else if (!inst.lib) inst.libError = COPY.filesError;
|
|
182
218
|
inst.loaded = true;
|
|
183
219
|
if (!v || v.ok === false) { inst.error = (v && v.error) || COPY.readError; inst.view = null; }
|
|
184
220
|
else { inst.error = null; inst.view = v; }
|
|
@@ -190,7 +226,7 @@ async function refresh(inst) {
|
|
|
190
226
|
function searchFiles(inst) {
|
|
191
227
|
if (inst.ftimer) clearTimeout(inst.ftimer);
|
|
192
228
|
const q = inst.q.trim();
|
|
193
|
-
if (!q) { inst.files = []; return; }
|
|
229
|
+
if (!q || inst.lib) { inst.files = []; return; }
|
|
194
230
|
inst.ftimer = setTimeout(async () => {
|
|
195
231
|
const seq = ++inst.fseq;
|
|
196
232
|
const r = await pmGet('/api/docs/files?ws=' + encodeURIComponent(inst.ws) + '&q=' + encodeURIComponent(q));
|
|
@@ -252,7 +288,7 @@ async function addCopy(inst, path, target, err) {
|
|
|
252
288
|
|
|
253
289
|
/* ── lines ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
254
290
|
|
|
255
|
-
function copyLine(inst, doc, link) {
|
|
291
|
+
function copyLine(inst, doc, link, q = '') {
|
|
256
292
|
const key = keyOf(doc, link);
|
|
257
293
|
const L = link.label || LABEL[link.kind];
|
|
258
294
|
const st = stateOf(inst, doc, link);
|
|
@@ -270,7 +306,7 @@ function copyLine(inst, doc, link) {
|
|
|
270
306
|
? el('a', { class: 'rp-docs-open', href: link.url, target: '_blank', rel: 'noopener noreferrer', 'data-docs-open': link.kind }, L + ' ↗')
|
|
271
307
|
: el('span', { class: 'rp-docs-open' }, L);
|
|
272
308
|
const line = row({
|
|
273
|
-
title: doc.title,
|
|
309
|
+
title: marked(doc.title, q),
|
|
274
310
|
meta: [st.strong, st.rest].filter(Boolean),
|
|
275
311
|
lead: [el('span', { class: 'rp-docs-dot rp-docs-dot--' + st.dot, 'aria-hidden': 'true' })],
|
|
276
312
|
trail: [opener, act].filter(Boolean),
|
|
@@ -304,6 +340,7 @@ function details(inst, doc, link, st) {
|
|
|
304
340
|
side('PMOS', doc.path, edit(doc.repo && doc.repo.modified_by, doc.repo && doc.repo.modified_at), pNew, 'pmos'),
|
|
305
341
|
side(L, link.target, edit(link.remote_modified_by, link.remote_modified_at), cNew, 'copy'),
|
|
306
342
|
diff,
|
|
343
|
+
inst.lib && inst.lib.byPath.has(doc.path) ? el('div', { class: 'rp-docs-links' }, fileGraph(inst.lib, doc.path, { onOpen: openFile })) : null,
|
|
307
344
|
el('div', { class: 'rp-docs-more' },
|
|
308
345
|
button({ label: COPY.compareAgain, variant: 'ghost', size: 'sm', disabled: !!(inst.view && (inst.view.open || inst.view.session)) || inst.busy,
|
|
309
346
|
attrs: { 'data-docs-act': 'compare-one' }, onClick: () => compareAll(inst) }),
|
|
@@ -311,18 +348,28 @@ function details(inst, doc, link, st) {
|
|
|
311
348
|
onClick: () => stopTracking(inst, doc, link) })));
|
|
312
349
|
}
|
|
313
350
|
|
|
314
|
-
/**
|
|
315
|
-
|
|
316
|
-
|
|
351
|
+
/**
|
|
352
|
+
* A file with no copy: its title, its initiative's short name (or its path) and the date of its last edit. A press shows
|
|
353
|
+
* its links; Add copy opens a paste field in place.
|
|
354
|
+
*/
|
|
355
|
+
function fileLine(inst, f, q = '') {
|
|
356
|
+
const start = () => { inst.adding = f.path; inst.graph = null; draw(inst); };
|
|
317
357
|
const adding = inst.adding === f.path;
|
|
358
|
+
const open = inst.graph === f.path;
|
|
359
|
+
const toggle = () => { inst.graph = open ? null : f.path; draw(inst); };
|
|
360
|
+
const ini = inst.lib && f.initiative ? inst.lib.ini(f) : null;
|
|
318
361
|
const line = row({
|
|
319
|
-
title: f.title,
|
|
320
|
-
meta: [
|
|
362
|
+
title: marked(f.title, q),
|
|
363
|
+
meta: [ini ? el('span', { class: 'rp-docs-ini', title: ini.title }, marked(ini.short, q)) : el('span', { class: 'rp-docs-path' }, marked(f.path, q)), day(f.date)],
|
|
321
364
|
lead: [el('span', { class: 'rp-docs-dot rp-docs-dot--none', 'aria-hidden': 'true' })],
|
|
322
|
-
trail: adding ? [] : [button({ label: COPY.addCopy, variant: 'outline', size: 'sm', attrs: { 'data-docs-act': 'add-copy', 'data-docs-key': f.path }, onClick: start })],
|
|
365
|
+
trail: adding ? [] : [button({ label: COPY.addCopy, variant: 'outline', size: 'sm', attrs: { class: 'rp-docs-addcopy', 'data-docs-act': 'add-copy', 'data-docs-key': f.path }, onClick: start })],
|
|
366
|
+
onOpen: inst.lib ? toggle : null,
|
|
367
|
+
openLabel: `${f.title}: show the files it links to`,
|
|
368
|
+
openAttrs: { 'aria-expanded': open ? 'true' : 'false', title: f.path, 'data-docs-file-toggle': f.path },
|
|
323
369
|
attrs: { 'data-docs-file-line': f.path, title: f.path },
|
|
324
370
|
});
|
|
325
371
|
const wrap = el('div', { class: 'rp-docs-row', role: 'listitem', 'data-docs-file': f.path }, line);
|
|
372
|
+
if (open && inst.lib) wrap.appendChild(el('div', { class: 'rp-docs-links' }, fileGraph(inst.lib, f.path, { onOpen: openFile })));
|
|
326
373
|
if (!adding) return wrap;
|
|
327
374
|
const err = el('span', { class: 'rp-docs-add__err', role: 'alert' });
|
|
328
375
|
const inp = field({ label: `Copy of ${f.title}`, hideLabel: true, placeholder: COPY.paste, attrs: { 'data-docs-field': 'target' } });
|
|
@@ -344,57 +391,110 @@ function header(inst) {
|
|
|
344
391
|
if (inst.head) return inst.head;
|
|
345
392
|
const find = field({ label: COPY.find, hideLabel: true, placeholder: COPY.find, attrs: { 'data-docs-find': '' } });
|
|
346
393
|
const q = find.control;
|
|
347
|
-
|
|
394
|
+
inst.findControl = q;
|
|
395
|
+
q.addEventListener('input', () => { inst.q = q.value; inst.open = null; inst.adding = null; inst.graph = null; inst.full.clear(); searchFiles(inst); draw(inst); });
|
|
348
396
|
// The field restores its last committed text on Escape; here Escape clears the search instead.
|
|
349
|
-
q.addEventListener('keydown', (e) => { if (e.key === 'Escape') { q.resetCommitted(''); inst.q = ''; searchFiles(inst); draw(inst); } });
|
|
397
|
+
q.addEventListener('keydown', (e) => { if (e.key === 'Escape') { q.resetCommitted(''); inst.q = ''; inst.graph = null; searchFiles(inst); draw(inst); } });
|
|
350
398
|
inst.head = el('div', { class: 'rp-docs-top' }, el('h2', { class: 'rp-head__title' }, COPY.title), el('div', { class: 'rp-docs-find' }, find));
|
|
351
399
|
return inst.head;
|
|
352
400
|
}
|
|
353
401
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
402
|
+
const needsYou = (inst, d, l) => l.signal !== 'same' || !!l.syncing || inst.pending.has(keyOf(d, l));
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* One category: its title (a fold when `foldable`), then its lines — files with a copy first, then newest first; the
|
|
406
|
+
* first 20, then Show all. `entries` are { f, doc }: f the file, doc its copies (null for a file with none).
|
|
407
|
+
*/
|
|
408
|
+
function section(inst, key, entries, { q = '', foldable = false, page = true } = {}) {
|
|
409
|
+
const name = CAT_NAME[key] || key;
|
|
410
|
+
const open = !foldable || inst.openCats.has(key);
|
|
411
|
+
const copies = entries.reduce((n, e) => n + (e.doc ? e.doc.links.length : 0), 0);
|
|
412
|
+
const needs = entries.reduce((n, e) => n + (e.doc ? e.doc.links.filter((l) => needsYou(inst, e.doc, l)).length : 0), 0);
|
|
413
|
+
const toggle = () => { if (inst.openCats.has(key)) inst.openCats.delete(key); else inst.openCats.add(key); draw(inst); };
|
|
414
|
+
const head = foldable
|
|
415
|
+
? row({ title: name, meta: [String(entries.length), needs ? COPY.needYou(needs) : null], lead: [icon(open ? 'chevron-down' : 'chevron-right')],
|
|
416
|
+
onOpen: toggle, openLabel: `${name}, ${entries.length} files${needs ? ', ' + COPY.needYou(needs) : ''}`,
|
|
417
|
+
openAttrs: { 'aria-expanded': open ? 'true' : 'false', 'data-docs-cat-toggle': key }, attrs: { class: 'rp-docs-cat', 'data-docs-cat': key, 'data-docs-count': String(entries.length) } })
|
|
418
|
+
: el('h3', { class: 'rp-docs-group__title' }, `${name} · ${entries.length}`);
|
|
419
|
+
const box = el('section', { class: 'rp-docs-group', 'data-docs-group': key }, head);
|
|
420
|
+
if (!open) return box;
|
|
421
|
+
const sorted = entries.slice().sort((a, b) => (!!b.doc - !!a.doc) || String(b.f.date || '').localeCompare(String(a.f.date || '')) || a.f.path.localeCompare(b.f.path));
|
|
422
|
+
const all = !page || inst.full.has(key);
|
|
423
|
+
const shown = all ? sorted : sorted.slice(0, PAGE);
|
|
424
|
+
const list = el('div', { role: 'list' }, shown.flatMap((e) => (e.doc ? e.doc.links.map((l) => copyLine(inst, e.doc, l, q)) : [fileLine(inst, e.f, q)])));
|
|
425
|
+
box.appendChild(list);
|
|
426
|
+
if (shown.length < sorted.length) {
|
|
427
|
+
box.appendChild(button({ label: COPY.showAll(sorted.length, name), variant: 'ghost', size: 'sm', attrs: { class: 'rp-docs-showall', 'data-docs-act': 'show-all', 'data-docs-key': key },
|
|
428
|
+
onClick: () => { inst.full.add(key); draw(inst); } }));
|
|
360
429
|
}
|
|
361
|
-
return
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
430
|
+
return box;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Entries grouped by category, in the categories' order. */
|
|
434
|
+
function byCategory(entries) {
|
|
435
|
+
const by = new Map();
|
|
436
|
+
for (const e of entries) { const k = CAT_NAME[e.f.category] ? e.f.category : 'guide'; if (!by.has(k)) by.set(k, []); by.get(k).push(e); }
|
|
437
|
+
return CATS.map(([k]) => [k, by.get(k)]).filter(([, es]) => es && es.length);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Every file: the library's, with each one's copies, plus a file with a copy the library does not list yet. */
|
|
441
|
+
function allEntries(inst) {
|
|
442
|
+
const v = inst.view;
|
|
443
|
+
const docOf = new Map(v.docs.map((d) => [d.path, d]));
|
|
444
|
+
const out = inst.lib ? inst.lib.files.map((f) => ({ f, doc: docOf.get(f.path) || null })) : [];
|
|
445
|
+
const listed = new Set(out.map((e) => e.f.path));
|
|
446
|
+
for (const d of v.docs) if (!listed.has(d.path)) out.push({ f: { path: d.path, title: d.title, category: d.category, date: null, initiative: null }, doc: d });
|
|
447
|
+
return out;
|
|
365
448
|
}
|
|
366
449
|
|
|
367
450
|
function body(inst) {
|
|
368
451
|
const v = inst.view;
|
|
369
452
|
const q = inst.q.trim().toLowerCase();
|
|
453
|
+
const lib = inst.lib;
|
|
454
|
+
if (inst.findControl) inst.findControl.placeholder = lib ? COPY.findIn(lib.files.length) : COPY.find;
|
|
370
455
|
if (q) {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
456
|
+
if (!lib) {
|
|
457
|
+
const hits = v.docs.filter((d) => [d.title, d.path, d.initiative ? d.initiative.title : ''].join(' ').toLowerCase().includes(q));
|
|
458
|
+
const rows = [...hits.flatMap((d) => d.links.map((l) => copyLine(inst, d, l, q))), ...inst.files.map((f) => fileLine(inst, f, q))];
|
|
459
|
+
return rows.length ? [el('div', { role: 'list', class: 'rp-docs-group', 'data-docs-results': '' }, rows)] : [el('p', { class: 'rp-section__empty' }, COPY.noMatch(inst.q.trim()))];
|
|
460
|
+
}
|
|
461
|
+
const hay = (e) => { const ini = e.f.initiative ? lib.ini(e.f) : null; return [e.f.title, e.f.path, ini ? ini.short + ' ' + ini.title : ''].join(' ').toLowerCase(); };
|
|
462
|
+
const hits = allEntries(inst).filter((e) => hay(e).includes(q));
|
|
463
|
+
if (!hits.length) return [el('p', { class: 'rp-section__empty' }, COPY.noMatch(inst.q.trim()))];
|
|
464
|
+
return [el('p', { class: 'rp-docs-hint', 'data-docs-results': String(hits.length) }, COPY.matches(hits.length, lib.files.length)),
|
|
465
|
+
...byCategory(hits).map(([k, es]) => section(inst, k, es, { q }))];
|
|
374
466
|
}
|
|
375
467
|
const all = v.docs.reduce((n, d) => n + d.links.length, 0);
|
|
376
|
-
const needs = v.docs.reduce((n, d) => n + d.links.filter((l) =>
|
|
468
|
+
const needs = v.docs.reduce((n, d) => n + d.links.filter((l) => needsYou(inst, d, l)).length, 0);
|
|
377
469
|
const running = !!(v.open || v.session) || inst.busy;
|
|
378
470
|
const out = [el('div', { class: 'rp-docs-views' },
|
|
379
|
-
tabs({ variant: 'line', items: [{ id: 'needs', label: COPY.needs, count: needs }, { id: '
|
|
380
|
-
|
|
471
|
+
tabs({ variant: 'line', items: [{ id: 'needs', label: COPY.needs, count: needs }, { id: 'copies', label: COPY.copies, count: all },
|
|
472
|
+
{ id: 'files', label: COPY.files, count: lib ? lib.files.length.toLocaleString('en-US') : null }],
|
|
473
|
+
value: inst.filter, ariaLabel: 'Filter', attrs: { 'data-docs-filter': '' }, onChange: (val) => { inst.filter = val; inst.graph = null; draw(inst); } }),
|
|
381
474
|
el('span', { class: 'rp-docs-when', 'data-docs-summary': '' },
|
|
382
475
|
running ? COPY.comparing : (v.last ? COPY.compared(v.last.finished_at) : COPY.never),
|
|
383
476
|
button({ label: '↻', variant: 'ghost', size: 'sm', disabled: running || !all,
|
|
384
477
|
attrs: { 'data-docs-act': 'check', 'aria-label': COPY.compareAll, title: COPY.compareAll }, onClick: () => compareAll(inst) })))];
|
|
478
|
+
if (inst.filter === 'files') {
|
|
479
|
+
if (!lib) { out.push(el('p', { class: 'rp-section__empty' + (inst.libError ? ' rp-section__empty--bad' : '') }, inst.libError || COPY.readingFiles)); return out; }
|
|
480
|
+
out.push(el('div', { class: 'rp-docs-folds' }, byCategory(allEntries(inst)).map(([k, es]) => section(inst, k, es, { foldable: true }))));
|
|
481
|
+
return out;
|
|
482
|
+
}
|
|
385
483
|
if (!v.docs.length) { out.push(el('p', { class: 'rp-section__empty' }, COPY.none)); return out; }
|
|
386
|
-
const
|
|
387
|
-
? v.docs.map((d) => ({ ...d, links: d.links.filter((l) =>
|
|
484
|
+
const docs = inst.filter === 'needs'
|
|
485
|
+
? v.docs.map((d) => ({ ...d, links: d.links.filter((l) => needsYou(inst, d, l)) })).filter((d) => d.links.length)
|
|
388
486
|
: v.docs;
|
|
389
|
-
if (!
|
|
390
|
-
|
|
487
|
+
if (!docs.length) { out.push(el('p', { class: 'rp-section__empty' }, COPY.nothingNeeds)); return out; }
|
|
488
|
+
const libFile = (d) => (lib && lib.byPath.has(d.path) ? lib.files[lib.byPath.get(d.path)] : { path: d.path, title: d.title, category: d.category, date: null });
|
|
489
|
+
out.push(...byCategory(docs.map((d) => ({ f: libFile(d), doc: d }))).map(([k, es]) => section(inst, k, es, { page: false })));
|
|
391
490
|
return out;
|
|
392
491
|
}
|
|
393
492
|
|
|
394
493
|
function draw(inst) {
|
|
395
494
|
const root = inst.root;
|
|
396
495
|
if (!inst.ws) { root.replaceChildren(empty({ text: COPY.noRepo })); inst.last = 'none'; return; }
|
|
397
|
-
const fp = JSON.stringify([inst.loaded, inst.error, inst.view, inst.filter, inst.busy, inst.q, inst.files, inst.open, inst.adding, [...inst.pending.keys()]
|
|
496
|
+
const fp = JSON.stringify([inst.loaded, inst.error, inst.view, inst.filter, inst.busy, inst.q, inst.files, inst.open, inst.adding, [...inst.pending.keys()],
|
|
497
|
+
inst.lib ? inst.lib.key : null, inst.libError, [...inst.openCats], [...inst.full], inst.graph]);
|
|
398
498
|
if (fp === inst.last && root.childNodes.length && root.contains(inst.body)) return;
|
|
399
499
|
inst.last = fp;
|
|
400
500
|
const head = header(inst);
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/* pm/doc-graph.js — the library of every repository file and the links the files write, drawn two ways (initiative
|
|
2
|
+
* docs-library-graph, PRD R-G1 to R-G5 and R-N1 to R-N5).
|
|
3
|
+
*
|
|
4
|
+
* getLibrary(ws) GET /api/docs/library once per workspace (kept for a short while), indexed
|
|
5
|
+
* fileGraph(lib, path, { onOpen }) one file in the middle: the files that link to it on the left, the files it
|
|
6
|
+
* links to on the right, at most 7 a side; a press on a file centres the graph on
|
|
7
|
+
* it, Back returns; List all N links lists every link by kind
|
|
8
|
+
* initiativeGraph(lib, paths, …) an initiative's files as filled dots and the outside files they link to as hollow
|
|
9
|
+
* dots (at most 14, the most-connected first); a press marks a dot's lines
|
|
10
|
+
*
|
|
11
|
+
* Every line is a link a file writes (lib/kg.js on the server): a markdown link, a path in the header, the initiative, the
|
|
12
|
+
* anchor, a decision number. Nothing is inferred. The layouts are computed from the files and links alone, so the same
|
|
13
|
+
* input draws the same picture. The pictures are an SVG of lines behind real buttons, placed with --rp-x and --rp-y. On a
|
|
14
|
+
* phone neither picture is drawn: the file graph shows its list, and the initiative sheet shows its own list.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { el } from './dom.js';
|
|
18
|
+
import { button, narrowViewport } from './primitives.js';
|
|
19
|
+
import { pmGet } from './http.js';
|
|
20
|
+
|
|
21
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
22
|
+
const MAX_SIDE = 7;
|
|
23
|
+
const MAX_OUTSIDE = 14;
|
|
24
|
+
const LIST_CAP = 12;
|
|
25
|
+
const KEEP_MS = 30000;
|
|
26
|
+
|
|
27
|
+
export const GROUP_OUT = [['initiative', 'Its initiative'], ['header', 'Named in its header'], ['link', 'Links to'], ['anchor', 'Anchored to'], ['cites', 'Decisions it cites']];
|
|
28
|
+
export const GROUP_IN = [['initiative', 'Its initiative'], ['header', 'Named by'], ['link', 'Linked from'], ['anchor', 'Anchors'], ['cites', 'Cited by']];
|
|
29
|
+
const ROLE = { spec: 'PRD', ini: 'Initiative record', exp: 'Explainer', proto: 'Prototype' };
|
|
30
|
+
|
|
31
|
+
/* ── the library ─────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
32
|
+
|
|
33
|
+
const libs = new Map(); // ws -> { at, promise }
|
|
34
|
+
|
|
35
|
+
/** The workspace's library, indexed, or null when it could not be read. One request per workspace while it is fresh. */
|
|
36
|
+
export function getLibrary(ws, { fresh = false } = {}) {
|
|
37
|
+
const hit = libs.get(ws);
|
|
38
|
+
if (hit && !fresh && Date.now() - hit.at < KEEP_MS) return hit.promise;
|
|
39
|
+
const promise = pmGet('/api/docs/library?ws=' + encodeURIComponent(ws)).then((r) => (r && r.ok ? indexed(r) : null));
|
|
40
|
+
libs.set(ws, { at: Date.now(), promise });
|
|
41
|
+
promise.then((v) => { if (!v) libs.delete(ws); });
|
|
42
|
+
return promise;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The response with lookups: byPath, out and inn (per file: [[neighbour, kind]]), and each file's initiative. */
|
|
46
|
+
export function indexed(r) {
|
|
47
|
+
const byPath = new Map(r.files.map((f, i) => [f.path, i]));
|
|
48
|
+
const out = r.files.map(() => []);
|
|
49
|
+
const inn = r.files.map(() => []);
|
|
50
|
+
for (const [a, b, k] of r.edges) { out[a].push([b, r.kinds[k]]); inn[b].push([a, r.kinds[k]]); }
|
|
51
|
+
const ini = (f) => (f.initiative && r.initiatives[f.initiative]) ? { id: f.initiative, ...r.initiatives[f.initiative] } : null;
|
|
52
|
+
return { ...r, byPath, out, inn, ini };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const KIND_RANK = { initiative: 0, header: 1, link: 2, anchor: 3, cites: 4 };
|
|
56
|
+
/** One entry per neighbour (its strongest kind), cites last, then the same initiative first, then newest first. */
|
|
57
|
+
function neighbours(lib, i) {
|
|
58
|
+
const me = lib.files[i].initiative;
|
|
59
|
+
const uniq = (xs) => {
|
|
60
|
+
const m = new Map();
|
|
61
|
+
for (const x of xs) if (x[0] !== i && (!m.has(x[0]) || KIND_RANK[x[1]] < KIND_RANK[m.get(x[0])[1]])) m.set(x[0], x);
|
|
62
|
+
return [...m.values()].sort((a, b) => {
|
|
63
|
+
const A = lib.files[a[0]]; const B = lib.files[b[0]];
|
|
64
|
+
return (a[1] === 'cites') - (b[1] === 'cites')
|
|
65
|
+
|| (B.initiative && B.initiative === me) - (A.initiative && A.initiative === me)
|
|
66
|
+
|| String(B.date || '').localeCompare(String(A.date || ''))
|
|
67
|
+
|| A.path.localeCompare(B.path);
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
return { out: uniq(lib.out[i]), inn: uniq(lib.inn[i]) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const clip = (t, n) => (t.length > n ? t.slice(0, n - 1) + '…' : t);
|
|
74
|
+
const svg = (tag, attrs = {}) => { const n = document.createElementNS(NS, tag); for (const [k, v] of Object.entries(attrs)) if (v != null) n.setAttribute(k, String(v)); return n; };
|
|
75
|
+
const place = (node, vars) => { for (const [k, v] of Object.entries(vars)) node.style.setProperty('--rp-' + k, String(Math.round(v * 100) / 100)); return node; };
|
|
76
|
+
|
|
77
|
+
/* ── one file's links ────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The links of one file. `onOpen(path, title)` opens a file in the reader. Returns an element that redraws itself: a press
|
|
81
|
+
* on a neighbour centres the graph on it, Back returns. On a phone the list is shown in place of the picture.
|
|
82
|
+
*/
|
|
83
|
+
export function fileGraph(lib, path, { onOpen } = {}) {
|
|
84
|
+
const start = lib.byPath.get(path);
|
|
85
|
+
const wrap = el('div', { class: 'rp-graph', 'data-graph': path });
|
|
86
|
+
if (start === undefined) { wrap.appendChild(el('p', { class: 'rp-graph__hint' }, 'This file is not in the library yet. It shows after its first commit.')); return wrap; }
|
|
87
|
+
const stack = [start];
|
|
88
|
+
const state = { list: false, more: new Set() };
|
|
89
|
+
const go = (j) => { stack.push(j); state.list = false; state.more.clear(); draw(); };
|
|
90
|
+
const draw = () => {
|
|
91
|
+
const i = stack[stack.length - 1];
|
|
92
|
+
const f = lib.files[i];
|
|
93
|
+
const { out, inn } = neighbours(lib, i);
|
|
94
|
+
const narrow = narrowViewport();
|
|
95
|
+
wrap.setAttribute('data-graph-focus', f.path);
|
|
96
|
+
const head = el('div', { class: 'rp-graph__head' },
|
|
97
|
+
stack.length > 1 ? button({ label: 'Back', icon: 'arrow-left', variant: 'ghost', size: 'sm', attrs: { 'data-graph-act': 'back' }, onClick: () => { stack.pop(); state.list = false; draw(); } }) : null,
|
|
98
|
+
el('span', { class: 'rp-graph__title' }, el('strong', null, clip(f.title, 80)), ` · ${inn.length === 1 ? '1 file links' : inn.length + ' files link'} here, it links to ${out.length}`),
|
|
99
|
+
onOpen ? button({ label: 'Open file', variant: 'outline', size: 'sm', attrs: { 'data-graph-act': 'open' }, onClick: () => onOpen(f.path, f.title) }) : null);
|
|
100
|
+
const kids = [head];
|
|
101
|
+
if (!inn.length && !out.length) {
|
|
102
|
+
kids.push(el('p', { class: 'rp-graph__hint' }, 'No file links to this one, and it links to none. Only links written in the files are shown.'));
|
|
103
|
+
wrap.replaceChildren(...kids);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!narrow) kids.push(picture(lib, i, inn, out, go));
|
|
107
|
+
const total = inn.length + out.length;
|
|
108
|
+
if (!narrow) {
|
|
109
|
+
kids.push(button({ label: state.list ? 'Hide the list' : `List all ${total} links`, variant: 'ghost', size: 'sm',
|
|
110
|
+
attrs: { 'data-graph-act': 'list', 'aria-expanded': state.list ? 'true' : 'false' }, onClick: () => { state.list = !state.list; draw(); } }));
|
|
111
|
+
}
|
|
112
|
+
if (narrow || state.list) kids.push(lists(lib, inn, out, go, state, draw));
|
|
113
|
+
kids.push(el('p', { class: 'rp-graph__hint' }, 'Only links written in the files count: a link, a path in the header, the initiative, or a decision number like D65. A dashed line is a decision it cites. Press a file to see its own links.'));
|
|
114
|
+
wrap.replaceChildren(...kids);
|
|
115
|
+
};
|
|
116
|
+
draw();
|
|
117
|
+
return wrap;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The picture: who links here (left), this file (middle), what it links to (right). */
|
|
121
|
+
function picture(lib, i, inn, out, go) {
|
|
122
|
+
const L = inn.slice(0, MAX_SIDE); const R = out.slice(0, MAX_SIDE);
|
|
123
|
+
const nL = L.length + (inn.length > MAX_SIDE ? 1 : 0); const nR = R.length + (out.length > MAX_SIDE ? 1 : 0);
|
|
124
|
+
const W = 820; const RH = 34; const NW = 250; const CW = 220;
|
|
125
|
+
const H = Math.max(nL, nR, 1) * RH + 16; const cx = W / 2; const cy = H / 2;
|
|
126
|
+
const yOf = (k, n) => cy + (k - (n - 1) / 2) * RH;
|
|
127
|
+
const pic = place(el('div', { class: 'rp-graph__pic', role: 'group', 'aria-label': `${lib.files[i].title}: ${inn.length} files link here, it links to ${out.length}` }), { ar: W / H });
|
|
128
|
+
const lines = svg('svg', { class: 'rp-graph__lines', viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: 'none', 'aria-hidden': 'true' });
|
|
129
|
+
const curve = (x1, y1, x2, y2, kind) => svg('path', { class: 'rp-graph__edge' + (kind === 'cites' ? ' rp-graph__edge--cites' : ''), d: `M${x1},${y1} C${(x1 + x2) / 2},${y1} ${(x1 + x2) / 2},${y2} ${x2},${y2}` });
|
|
130
|
+
L.forEach(([, k], n) => lines.appendChild(curve(NW, yOf(n, nL), cx - CW / 2, cy, k)));
|
|
131
|
+
R.forEach(([, k], n) => lines.appendChild(curve(cx + CW / 2, cy, W - NW, yOf(n, nR), k)));
|
|
132
|
+
pic.appendChild(lines);
|
|
133
|
+
const node = (j, kind, x, y, side) => place(button({ label: clip(lib.files[j].title, 40), variant: 'outline', size: 'sm',
|
|
134
|
+
attrs: { class: 'rp-graph__node rp-graph__node--' + side + (kind === 'cites' ? ' rp-graph__node--cites' : ''), title: `${lib.files[j].title}\n${lib.files[j].path}`,
|
|
135
|
+
'data-graph-node': lib.files[j].path, 'data-graph-side': side, 'data-graph-kind': kind }, onClick: () => go(j) }), { x: (x / W) * 100, y: (y / H) * 100, w: (NW / W) * 100 });
|
|
136
|
+
L.forEach(([j, k], n) => pic.appendChild(node(j, k, 0, yOf(n, nL), 'in')));
|
|
137
|
+
R.forEach(([j, k], n) => pic.appendChild(node(j, k, W - NW, yOf(n, nR), 'out')));
|
|
138
|
+
const more = (n, x, y, side) => place(el('span', { class: 'rp-graph__more rp-graph__more--' + side }, `+ ${n} more in the list`), { x: (x / W) * 100, y: (y / H) * 100, w: (NW / W) * 100 });
|
|
139
|
+
if (inn.length > MAX_SIDE) pic.appendChild(more(inn.length - MAX_SIDE, 0, yOf(L.length, nL), 'in'));
|
|
140
|
+
if (out.length > MAX_SIDE) pic.appendChild(more(out.length - MAX_SIDE, W - NW, yOf(R.length, nR), 'out'));
|
|
141
|
+
pic.appendChild(place(el('span', { class: 'rp-graph__centre', title: lib.files[i].path }, clip(lib.files[i].title, 34)), { x: ((cx - CW / 2) / W) * 100, y: (cy / H) * 100, w: (CW / W) * 100 }));
|
|
142
|
+
pic.appendChild(el('span', { class: 'rp-graph__cap rp-graph__cap--in' }, 'Links here'));
|
|
143
|
+
pic.appendChild(el('span', { class: 'rp-graph__cap rp-graph__cap--out' }, 'It links to'));
|
|
144
|
+
return pic;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Every link, both directions, grouped by kind. A group longer than 12 shows its first 12 and a Show all. */
|
|
148
|
+
function lists(lib, inn, out, go, state, draw) {
|
|
149
|
+
const col = (xs, groups, label, side) => el('div', { class: 'rp-graph__col', 'data-graph-list': side },
|
|
150
|
+
el('span', { class: 'rp-graph__label' }, label),
|
|
151
|
+
groups.map(([kind, name]) => {
|
|
152
|
+
const g = xs.filter((x) => x[1] === kind);
|
|
153
|
+
if (!g.length) return null;
|
|
154
|
+
const key = side + kind;
|
|
155
|
+
const shown = state.more.has(key) ? g : g.slice(0, LIST_CAP);
|
|
156
|
+
return el('div', { class: 'rp-graph__group' },
|
|
157
|
+
el('span', { class: 'rp-graph__label' }, `${name} · ${g.length}`),
|
|
158
|
+
shown.map(([j]) => button({ label: lib.files[j].title, variant: 'ghost', size: 'sm', attrs: { class: 'rp-graph__item', title: lib.files[j].path, 'data-graph-item': lib.files[j].path }, onClick: () => go(j) })),
|
|
159
|
+
g.length > shown.length ? button({ label: `Show all ${g.length}`, variant: 'ghost', size: 'sm', onClick: () => { state.more.add(key); draw(); } }) : null);
|
|
160
|
+
}));
|
|
161
|
+
return el('div', { class: 'rp-graph__lists' }, col(inn, GROUP_IN, 'Links here', 'in'), col(out, GROUP_OUT, 'It links to', 'out'));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* ── an initiative's files ───────────────────────────────────────────────────────────────────────────────────────── */
|
|
165
|
+
|
|
166
|
+
/** The short label of a dot: a decision's number, the role of an initiative's file, or else its title, shortened. */
|
|
167
|
+
export function roleOf(f) {
|
|
168
|
+
const d = /^(D\d+)\b/.exec(f.title);
|
|
169
|
+
if (d) return d[1];
|
|
170
|
+
if (f.category === 'eval') return /rubric/i.test(f.title + ' ' + f.path) ? 'Rubric' : /verdict/i.test(f.path) ? 'Verdict' : 'Eval';
|
|
171
|
+
return ROLE[f.category] || clip(f.title.replace(/^[^:]{1,24}:\s+/, ''), 22);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The positions of an initiative's network: its files (inner) and the outside files they link to (outer, at most 14).
|
|
176
|
+
* Pure and deterministic: the same files and links give the same positions. Exported for the tests.
|
|
177
|
+
*/
|
|
178
|
+
export function networkLayout(lib, paths, { W = 820, H = 460 } = {}) {
|
|
179
|
+
const inner = [...new Set(paths.map((p) => lib.byPath.get(p)).filter((x) => x !== undefined))].sort((a, b) => lib.files[a].path.localeCompare(lib.files[b].path));
|
|
180
|
+
const isIn = new Set(inner);
|
|
181
|
+
const score = new Map();
|
|
182
|
+
for (const i of inner) for (const [j] of [...lib.out[i], ...lib.inn[i]]) if (!isIn.has(j)) score.set(j, (score.get(j) || 0) + 1);
|
|
183
|
+
const outside = [...score.entries()].sort((a, b) => b[1] - a[1] || String(lib.files[b[0]].date || '').localeCompare(String(lib.files[a[0]].date || '')) || lib.files[a[0]].path.localeCompare(lib.files[b[0]].path));
|
|
184
|
+
const outer = outside.slice(0, MAX_OUTSIDE).map((x) => x[0]);
|
|
185
|
+
const ids = [...inner, ...outer];
|
|
186
|
+
const on = new Set(ids);
|
|
187
|
+
const cx = W / 2; const cy = H / 2;
|
|
188
|
+
const pos = new Map();
|
|
189
|
+
ids.forEach((i, k) => {
|
|
190
|
+
const mine = isIn.has(i); const n = mine ? inner.length : outer.length; const kk = mine ? k : k - inner.length;
|
|
191
|
+
const a = (kk / Math.max(n, 1)) * Math.PI * 2 + (mine ? 0 : 0.3); const r = mine ? 70 : 190;
|
|
192
|
+
pos.set(i, { x: cx + r * Math.cos(a) * 1.6, y: cy + r * Math.sin(a), vx: 0, vy: 0 });
|
|
193
|
+
});
|
|
194
|
+
const edges = [];
|
|
195
|
+
const seen = new Set();
|
|
196
|
+
for (const i of ids) for (const [j, kind] of lib.out[i]) {
|
|
197
|
+
if (!on.has(j) || i === j || !(isIn.has(i) || isIn.has(j))) continue;
|
|
198
|
+
const key = i + '>' + j;
|
|
199
|
+
if (seen.has(key)) continue;
|
|
200
|
+
seen.add(key); edges.push([i, j, kind]);
|
|
201
|
+
}
|
|
202
|
+
for (let it = 0; it < 500; it++) {
|
|
203
|
+
for (let a = 0; a < ids.length; a++) for (let b = a + 1; b < ids.length; b++) {
|
|
204
|
+
const A = pos.get(ids[a]); const B = pos.get(ids[b]);
|
|
205
|
+
let dx = A.x - B.x; let dy = (A.y - B.y) * 1.6;
|
|
206
|
+
const d2 = Math.max(dx * dx + dy * dy, 25); const f = 26000 / d2; const d = Math.sqrt(d2);
|
|
207
|
+
dx /= d; dy /= d; A.vx += dx * f; A.vy += dy * f; B.vx -= dx * f; B.vy -= dy * f;
|
|
208
|
+
}
|
|
209
|
+
for (const [i, j] of edges) {
|
|
210
|
+
const A = pos.get(i); const B = pos.get(j);
|
|
211
|
+
const dx = B.x - A.x; const dy = B.y - A.y; const d = Math.sqrt(dx * dx + dy * dy) || 1; const f = (d - 150) * 0.02;
|
|
212
|
+
A.vx += (dx / d) * f; A.vy += (dy / d) * f; B.vx -= (dx / d) * f; B.vy -= (dy / d) * f;
|
|
213
|
+
}
|
|
214
|
+
for (const i of ids) {
|
|
215
|
+
const P = pos.get(i); const g = isIn.has(i) ? 0.02 : 0.005;
|
|
216
|
+
P.vx += (cx - P.x) * g; P.vy += (cy - P.y) * g * 1.4;
|
|
217
|
+
P.x += Math.max(-20, Math.min(20, P.vx * 0.5)); P.y += Math.max(-20, Math.min(20, P.vy * 0.5));
|
|
218
|
+
P.vx *= 0.5; P.vy *= 0.5;
|
|
219
|
+
P.x = Math.max(30, Math.min(W - 160, P.x)); P.y = Math.max(22, Math.min(H - 22, P.y));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const at = new Map(ids.map((i) => [i, { x: Math.round(pos.get(i).x * 10) / 10, y: Math.round(pos.get(i).y * 10) / 10 }]));
|
|
223
|
+
return { W, H, inner, outer, left: outside.length - outer.length, edges, at };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* An initiative's files as a network. `onOpen(path, title)` opens a file. Returns null on a phone: the caller shows its
|
|
228
|
+
* list instead (R-N5).
|
|
229
|
+
*/
|
|
230
|
+
export function initiativeGraph(lib, paths, { onOpen } = {}) {
|
|
231
|
+
if (narrowViewport()) return null;
|
|
232
|
+
const L = networkLayout(lib, paths);
|
|
233
|
+
const isIn = new Set(L.inner);
|
|
234
|
+
const wrap = el('div', { class: 'rp-net', 'data-net': '' });
|
|
235
|
+
if (!L.inner.length) { wrap.appendChild(el('p', { class: 'rp-graph__hint' }, 'None of this initiative’s files is in the library yet. They show after their first commit.')); return wrap; }
|
|
236
|
+
let sel = null;
|
|
237
|
+
const draw = () => {
|
|
238
|
+
const pic = place(el('div', { class: 'rp-net__pic', role: 'group', 'aria-label': `${L.inner.length} files of this initiative and ${L.outer.length} files outside it that they link to` }), { ar: L.W / L.H });
|
|
239
|
+
const lines = svg('svg', { class: 'rp-net__lines', viewBox: `0 0 ${L.W} ${L.H}`, preserveAspectRatio: 'none', 'aria-hidden': 'true' });
|
|
240
|
+
for (const [i, j, kind] of L.edges) {
|
|
241
|
+
const A = L.at.get(i); const B = L.at.get(j);
|
|
242
|
+
const cls = ['rp-net__edge', kind === 'cites' && 'rp-net__edge--cites', isIn.has(i) && isIn.has(j) && 'rp-net__edge--in', sel !== null && (sel === i || sel === j) && 'rp-net__edge--hot'].filter(Boolean).join(' ');
|
|
243
|
+
lines.appendChild(svg('line', { class: cls, x1: A.x, y1: A.y, x2: B.x, y2: B.y, 'data-net-edge': `${lib.files[i].path}>${lib.files[j].path}` }));
|
|
244
|
+
}
|
|
245
|
+
pic.appendChild(lines);
|
|
246
|
+
for (const i of [...L.inner, ...L.outer]) {
|
|
247
|
+
const f = lib.files[i]; const mine = isIn.has(i); const P = L.at.get(i);
|
|
248
|
+
const label = mine ? roleOf(f) : ((/^(D\d+)\b/.exec(f.title) || [])[1] || clip(f.title, 22));
|
|
249
|
+
pic.appendChild(place(button({ label: [el('span', { class: 'rp-net__dot' + (mine ? ' rp-net__dot--in' : ''), 'aria-hidden': 'true' }), el('span', { class: 'rp-net__label' }, label)],
|
|
250
|
+
variant: 'ghost', size: 'sm',
|
|
251
|
+
attrs: { class: 'rp-net__node' + (mine ? ' rp-net__node--in' : '') + (sel === i ? ' rp-net__node--sel' : ''), title: `${f.title}\n${f.path}`, 'aria-label': f.title, 'aria-pressed': sel === i ? 'true' : 'false',
|
|
252
|
+
'data-net-node': f.path, 'data-net-side': mine ? 'in' : 'out' },
|
|
253
|
+
onClick: () => { sel = sel === i ? null : i; draw(); } }), { x: (P.x / L.W) * 100, y: (P.y / L.H) * 100 }));
|
|
254
|
+
}
|
|
255
|
+
const legend = el('div', { class: 'rp-net__legend' },
|
|
256
|
+
el('span', null, el('span', { class: 'rp-net__dot rp-net__dot--in', 'aria-hidden': 'true' }), 'This initiative'),
|
|
257
|
+
el('span', null, el('span', { class: 'rp-net__dot', 'aria-hidden': 'true' }), 'Outside it'),
|
|
258
|
+
el('span', null, el('span', { class: 'rp-net__dash', 'aria-hidden': 'true' }), 'Cites a decision'),
|
|
259
|
+
L.left ? el('span', { 'data-net-left': String(L.left) }, `${L.left} more outside files are left out. Open a file in Docs to see all its links.`) : null);
|
|
260
|
+
const kids = [pic, legend];
|
|
261
|
+
if (sel !== null) {
|
|
262
|
+
const f = lib.files[sel];
|
|
263
|
+
const ini = lib.ini(f);
|
|
264
|
+
kids.push(el('div', { class: 'rp-net__sel', 'data-net-sel': f.path },
|
|
265
|
+
el('span', { class: 'rp-net__sel-text' }, el('strong', null, f.title), el('span', { class: 'rp-net__path' }, f.path + (ini && !isIn.has(sel) ? ' · ' + ini.short : ''))),
|
|
266
|
+
onOpen ? button({ label: 'Open file', variant: 'outline', size: 'sm', attrs: { 'data-net-act': 'open' }, onClick: () => onOpen(f.path, f.title) }) : null));
|
|
267
|
+
}
|
|
268
|
+
wrap.replaceChildren(...kids);
|
|
269
|
+
};
|
|
270
|
+
draw();
|
|
271
|
+
return wrap;
|
|
272
|
+
}
|