projectkeeper 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +283 -2
- package/README.zh-CN.md +285 -0
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/bin/pk.mjs +6 -0
- package/bin/postinstall.mjs +5 -0
- package/bin/strip-types.mjs +28 -0
- package/package.json +68 -4
- package/patches/README.md +111 -0
- package/patches/pi-ai-0.85.0.ts +122 -0
- package/patches/pi-ai-0.87.1.ts +122 -0
- package/scripts/apply-pi-streaming-patch.ts +86 -0
- package/skills/clerk/coverage/SKILL.md +35 -0
- package/skills/clerk/cross-check/SKILL.md +118 -0
- package/skills/clerk/deepen-dispatch/SKILL.md +69 -0
- package/skills/clerk/lane-follow-up/SKILL.md +94 -0
- package/skills/clerk/lane-plan/SKILL.md +107 -0
- package/skills/clerk/lane-slot/SKILL.md +113 -0
- package/skills/clerk/lane-topic/SKILL.md +116 -0
- package/skills/clerk/orientation/SKILL.md +160 -0
- package/skills/clerk/reconcile/SKILL.md +77 -0
- package/skills/clerk/skeleton-dispatch/SKILL.md +43 -0
- package/skills/clerk/spot-check/SKILL.md +52 -0
- package/skills/clerk/synthesis/SKILL.md +65 -0
- package/skills/table-follow-up/SKILL.md +42 -0
- package/src/cli.ts +319 -0
- package/src/codemap/README.md +13 -0
- package/src/codemap/engines.ts +102 -0
- package/src/codemap/facts.ts +504 -0
- package/src/context/agent-entry.ts +232 -0
- package/src/context/ask.ts +97 -0
- package/src/context/assemble.ts +1058 -0
- package/src/context/code-source.ts +103 -0
- package/src/context/k-briefs.ts +114 -0
- package/src/context/lineage.ts +100 -0
- package/src/context/object-brief.ts +300 -0
- package/src/context/owner-words.ts +191 -0
- package/src/context/pack-fixture.ts +342 -0
- package/src/context/project-rules.ts +160 -0
- package/src/intake/intake.ts +392 -0
- package/src/intake/material-rules.ts +166 -0
- package/src/intake/removal.ts +316 -0
- package/src/intake/skipped.ts +72 -0
- package/src/keeper/adjustment.ts +565 -0
- package/src/keeper/bounds/boundary.ts +509 -0
- package/src/keeper/bounds/command.ts +1665 -0
- package/src/keeper/bounds/paths.ts +164 -0
- package/src/keeper/bounds/reads.ts +718 -0
- package/src/keeper/bounds/shell-write-guard.ts +521 -0
- package/src/keeper/capabilities.ts +136 -0
- package/src/keeper/clerk-steps.ts +151 -0
- package/src/keeper/clerk-tools.ts +1424 -0
- package/src/keeper/conversation.ts +253 -0
- package/src/keeper/evidence.ts +662 -0
- package/src/keeper/fake-provider.ts +132 -0
- package/src/keeper/held.ts +15 -0
- package/src/keeper/history-tools.ts +163 -0
- package/src/keeper/jobs/answering.ts +53 -0
- package/src/keeper/key-check.ts +105 -0
- package/src/keeper/ledger-tools.ts +295 -0
- package/src/keeper/link-check.ts +185 -0
- package/src/keeper/merge.ts +260 -0
- package/src/keeper/note-origin.ts +56 -0
- package/src/keeper/numbers-check.ts +102 -0
- package/src/keeper/open-in-pi.ts +57 -0
- package/src/keeper/organize/absence.ts +266 -0
- package/src/keeper/organize/carried-on.ts +214 -0
- package/src/keeper/organize/clerk-coverage.ts +293 -0
- package/src/keeper/organize/clerk-prompts.ts +132 -0
- package/src/keeper/organize/clerk.ts +1395 -0
- package/src/keeper/organize/coverage-gate.ts +19 -0
- package/src/keeper/organize/coverage-tools.ts +765 -0
- package/src/keeper/organize/entries.ts +153 -0
- package/src/keeper/organize/entry-gate.ts +289 -0
- package/src/keeper/organize/fill-tools.ts +718 -0
- package/src/keeper/organize/follow-up.ts +280 -0
- package/src/keeper/organize/generation-check.ts +504 -0
- package/src/keeper/organize/graph.ts +230 -0
- package/src/keeper/organize/lane-tools.ts +289 -0
- package/src/keeper/organize/looked-tools.ts +54 -0
- package/src/keeper/organize/materials.ts +243 -0
- package/src/keeper/organize/owner-lines.ts +493 -0
- package/src/keeper/organize/place-tools.ts +216 -0
- package/src/keeper/organize/placement-inference.ts +1045 -0
- package/src/keeper/organize/placement-time.ts +320 -0
- package/src/keeper/organize/placing.ts +821 -0
- package/src/keeper/organize/positioning.ts +33 -0
- package/src/keeper/organize/readiness.ts +47 -0
- package/src/keeper/organize/reading.ts +401 -0
- package/src/keeper/organize/round-blocks.ts +65 -0
- package/src/keeper/organize/round-news.ts +164 -0
- package/src/keeper/organize/round-open.ts +586 -0
- package/src/keeper/organize/schedule-state.ts +39 -0
- package/src/keeper/organize/schedule.ts +137 -0
- package/src/keeper/organize/service.ts +409 -0
- package/src/keeper/organize/skills.ts +116 -0
- package/src/keeper/organize/slots.ts +85 -0
- package/src/keeper/organize/stage-gate.ts +175 -0
- package/src/keeper/organize/stage-tools.ts +471 -0
- package/src/keeper/organize/standing-notes.ts +141 -0
- package/src/keeper/organize/table-follow-up.ts +330 -0
- package/src/keeper/organize/takeover-clerk.ts +832 -0
- package/src/keeper/organize/takeover-state.ts +103 -0
- package/src/keeper/organize/takeover.ts +216 -0
- package/src/keeper/organize/update-pending.ts +535 -0
- package/src/keeper/organize/workbench-placement.ts +92 -0
- package/src/keeper/own-keys.ts +114 -0
- package/src/keeper/owner-text.ts +165 -0
- package/src/keeper/project-folder.ts +334 -0
- package/src/keeper/prompts.ts +101 -0
- package/src/keeper/references.ts +177 -0
- package/src/keeper/relook/compose.ts +120 -0
- package/src/keeper/relook/prompts.ts +36 -0
- package/src/keeper/roles.ts +259 -0
- package/src/keeper/round-tools.ts +227 -0
- package/src/keeper/route.ts +148 -0
- package/src/keeper/rules.ts +97 -0
- package/src/keeper/runtime.ts +1858 -0
- package/src/keeper/scope-tools.ts +194 -0
- package/src/keeper/sendback-text.ts +83 -0
- package/src/keeper/step-timing.ts +207 -0
- package/src/keeper/testing/clerk-script.ts +212 -0
- package/src/keeper/testing/streaming-arguments.ts +57 -0
- package/src/keeper/tools.ts +2340 -0
- package/src/keeper/utterance-tools.ts +64 -0
- package/src/keeper/verbatim.ts +397 -0
- package/src/ledger/adapters.ts +186 -0
- package/src/ledger/arrangements.ts +257 -0
- package/src/ledger/blame.ts +58 -0
- package/src/ledger/code-engine-child.ts +69 -0
- package/src/ledger/code-engine.ts +548 -0
- package/src/ledger/code.ts +607 -0
- package/src/ledger/docs.ts +294 -0
- package/src/ledger/git-read.ts +321 -0
- package/src/ledger/index.ts +1850 -0
- package/src/ledger/item-range.ts +16 -0
- package/src/ledger/lines.ts +328 -0
- package/src/ledger/numbering.ts +269 -0
- package/src/ledger/ranges.ts +74 -0
- package/src/ledger/rebuild-worker.ts +15 -0
- package/src/ledger/rebuild.ts +387 -0
- package/src/ledger/repo-scan.ts +181 -0
- package/src/ledger/schema.ts +450 -0
- package/src/ledger/sessions.ts +134 -0
- package/src/ledger/text-scan.ts +466 -0
- package/src/ledger/time.ts +185 -0
- package/src/ledger/ts-symbols.ts +180 -0
- package/src/ledger/views.ts +377 -0
- package/src/mcp/server.ts +127 -0
- package/src/mcp/tools.ts +187 -0
- package/src/model/ids.ts +23 -0
- package/src/model/k-types.ts +912 -0
- package/src/model/time.ts +85 -0
- package/src/model/types.ts +1382 -0
- package/src/model/views-k.ts +707 -0
- package/src/model/vocab.ts +269 -0
- package/src/process/analysis.ts +79 -0
- package/src/process/breakpoint-candidates.ts +383 -0
- package/src/process/breakpoints.ts +746 -0
- package/src/process/claim-check.ts +295 -0
- package/src/process/delivery-links.ts +52 -0
- package/src/process/facts.ts +487 -0
- package/src/process/generations.ts +138 -0
- package/src/process/index.ts +243 -0
- package/src/process/integration.ts +128 -0
- package/src/process/placement.ts +107 -0
- package/src/process/plan.ts +276 -0
- package/src/process/sendbacks.ts +244 -0
- package/src/process/text.ts +185 -0
- package/src/process/units.ts +543 -0
- package/src/process/unnumbered.ts +431 -0
- package/src/process/work.ts +884 -0
- package/src/scope/candidates.ts +86 -0
- package/src/scope/decisions.ts +229 -0
- package/src/scope/discover.ts +741 -0
- package/src/scope/ignore.ts +126 -0
- package/src/scope/ignored-place.ts +93 -0
- package/src/scope/skip.ts +161 -0
- package/src/scope/toolchain.ts +180 -0
- package/src/scope/worktree.ts +187 -0
- package/src/server/api.ts +549 -0
- package/src/server/app.ts +595 -0
- package/src/server/drafts-api.ts +170 -0
- package/src/server/folders-api.ts +243 -0
- package/src/server/graph-view.ts +639 -0
- package/src/server/http.ts +224 -0
- package/src/server/k-agent-api.ts +56 -0
- package/src/server/k-rounds-d99.ts +91 -0
- package/src/server/k-views.ts +405 -0
- package/src/server/keeper-page.ts +446 -0
- package/src/server/keys-api.ts +239 -0
- package/src/server/project-folder-api.ts +104 -0
- package/src/server/vendor.ts +14 -0
- package/src/server/workbench-content.ts +561 -0
- package/src/sources/anchor.ts +129 -0
- package/src/sources/files.ts +267 -0
- package/src/sources/gitobs.ts +41 -0
- package/src/sources/history.ts +231 -0
- package/src/sources/scan-worker.ts +10 -0
- package/src/sources/sessions/locate.ts +292 -0
- package/src/sources/sessions/read.ts +346 -0
- package/src/sources/sessions/scope.ts +42 -0
- package/src/sources/sessions/utterances.ts +223 -0
- package/src/sources/watch.ts +215 -0
- package/src/store/json-file.ts +50 -0
- package/src/store/paths.ts +39 -0
- package/src/store/project-store.ts +295 -0
- package/src/store/versions.ts +172 -0
- package/src/store/workspace.ts +216 -0
- package/src/table/check.ts +333 -0
- package/src/table/follow-up-tools.ts +30 -0
- package/src/table/follow-up.ts +344 -0
- package/src/table/import.ts +610 -0
- package/src/table/project-home.ts +61 -0
- package/src/table/sessions.ts +168 -0
- package/src/table/takeover.ts +483 -0
- package/src/util/git-check.ts +29 -0
- package/src/util/git.ts +411 -0
- package/src/util/own-ts.ts +15 -0
- package/src/util/paths.ts +225 -0
- package/template/FILLING-IN.md +148 -0
- package/template/examples/papertrail.json +747 -0
- package/template/projectkeeper.schema.json +1001 -0
- package/ui/app.js +1135 -0
- package/ui/failures.js +82 -0
- package/ui/flyout.js +120 -0
- package/ui/folder-chooser.js +264 -0
- package/ui/glossary.js +156 -0
- package/ui/graph-fit.js +157 -0
- package/ui/graph-material.js +61 -0
- package/ui/graph-palette.js +109 -0
- package/ui/graph-tools.js +62 -0
- package/ui/graph.js +1712 -0
- package/ui/index.html +31 -0
- package/ui/k/code-view.js +230 -0
- package/ui/k/draft-view.js +93 -0
- package/ui/k/k-views.css +224 -0
- package/ui/k/round-tree.js +450 -0
- package/ui/k/scope-k.js +159 -0
- package/ui/k-data.js +118 -0
- package/ui/k-fold.js +106 -0
- package/ui/k-process.css +302 -0
- package/ui/k-process.js +934 -0
- package/ui/keeper-activity.js +175 -0
- package/ui/keeper-view.js +801 -0
- package/ui/keeper.css +101 -0
- package/ui/key-order.js +62 -0
- package/ui/list-fold.js +67 -0
- package/ui/markdown.d.ts +21 -0
- package/ui/markdown.js +472 -0
- package/ui/placement.d.ts +92 -0
- package/ui/placement.js +445 -0
- package/ui/popover-place.d.ts +16 -0
- package/ui/popover-place.js +89 -0
- package/ui/popover.js +198 -0
- package/ui/project-folder.js +37 -0
- package/ui/shell-state.js +40 -0
- package/ui/strip-rows.js +171 -0
- package/ui/styles.css +492 -0
- package/ui/theme-pick.js +36 -0
- package/ui/theme.js +74 -0
- package/ui/themes/LICENSE.md +13 -0
- package/ui/themes/README.md +44 -0
- package/ui/themes/TOKENS.md +291 -0
- package/ui/themes/a1/board.css +1557 -0
- package/ui/themes/a1/pk.css +392 -0
- package/ui/themes/b1/board.css +1163 -0
- package/ui/themes/b1/pk.css +218 -0
- package/ui/themes/d2/board.css +518 -0
- package/ui/themes/d2/pk.css +243 -0
- package/ui/themes/guard.css +73 -0
- package/ui/themes/i1/board.css +776 -0
- package/ui/themes/i1/pk.css +223 -0
- package/ui/themes/manifest.json +13 -0
- package/ui/views.js +2796 -0
package/ui/graph.js
ADDED
|
@@ -0,0 +1,1712 @@
|
|
|
1
|
+
// Project graph renderer on cytoscape (Spec §6.3). It draws the assets the API returns; visibility rules, scales,
|
|
2
|
+
// focus, filters and groups live here. Fixed text is English.
|
|
3
|
+
import { TEXT, MIN_TEXT_PX, ZOOM_FLOOR, ZOOM_HARD_MIN, fitView, openingView, wholeView, hardMinZoom, keepInView, inView, panToShow, pictureBox } from './graph-fit.js';
|
|
4
|
+
import { FACTORY_PALETTE, readPalette, tint, MARK_COLOURS } from './graph-palette.js';
|
|
5
|
+
import { materialOf, materialSvg, materialRoom } from './graph-material.js';
|
|
6
|
+
import { placementOf, cellKey, NO_PLAN, INTENT_KINDS, destinationLine, destinationText, isCopyIn, sharedMark, areaWorkLine, MAIN_BY, FOUNDATION_LABEL, WHOLE_LABEL, WHOLE_PLAN_LABEL } from './placement.js';
|
|
7
|
+
export { placementOf, objectAttentionNotes } from './placement.js';
|
|
8
|
+
|
|
9
|
+
// ── What an object looks like (D40, D53) ─────────────────────────────────
|
|
10
|
+
// Which List column (Spec §6.3) each category belongs to. Shape and colour both follow the column (D53): Product
|
|
11
|
+
// intent is a document, Work & plan a long hexagon, Observed reality a folder at the foot of each column (an oval when
|
|
12
|
+
// drawn one by one). Plan and Work item share the hexagon, so they are the one pair told apart by colour alone.
|
|
13
|
+
export const CATEGORY_COLUMN = {
|
|
14
|
+
"Owner's words": 'Product intent', 'Product': 'Product intent', 'Goal': 'Product intent', 'Area': 'Product intent', 'Decision': 'Product intent', 'Requirement': 'Product intent', 'Design': 'Product intent',
|
|
15
|
+
'Plan': 'Work & plan', 'Work item': 'Work & plan',
|
|
16
|
+
'Result': 'Observed reality', 'Review': 'Observed reality', 'Test': 'Observed reality', 'Session': 'Observed reality', 'Run': 'Observed reality', 'Change': 'Change',
|
|
17
|
+
};
|
|
18
|
+
// The colours come from the theme in force (Spec §6.16): a palette read from the `--pk-graph-*` tokens, the factory
|
|
19
|
+
// values behind them — the workbench's own blue, purple, green and a peach that is neither its warning red nor its amber,
|
|
20
|
+
// checked with scripts/palette-check.py (CIEDE2000; protanopia and deuteranopia simulated after
|
|
21
|
+
// Machado 2009): Plan / Work item 30.4 normal, 25.2 and 21.1 simulated; the four within 3.2 L* of each other, so no kind
|
|
22
|
+
// jumps out (Spec §6.3). Each theme gives its own four (ui/themes/TOKENS.md) and is checked the same way.
|
|
23
|
+
let palette = FACTORY_PALETTE;
|
|
24
|
+
export const currentPalette = () => palette;
|
|
25
|
+
/** Read the palette off the page again: after the theme's stylesheets have loaded (theme.js), before repainting. */
|
|
26
|
+
export function refreshPalette() {
|
|
27
|
+
if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') return palette;
|
|
28
|
+
const cs = getComputedStyle(document.documentElement);
|
|
29
|
+
palette = readPalette((name) => cs.getPropertyValue(name));
|
|
30
|
+
materials.clear();
|
|
31
|
+
return palette;
|
|
32
|
+
}
|
|
33
|
+
export const COLUMN_COLOR = Object.freeze({ get intent() { return palette.intent; }, get plan() { return palette.plan; }, get work() { return palette.work; }, get reality() { return palette.reality; } });
|
|
34
|
+
export const colourKey = (category) => category === 'Plan' ? 'plan' : category === 'Work item' ? 'work' : CATEGORY_COLUMN[category] === 'Observed reality' ? 'reality' : 'intent';
|
|
35
|
+
export const CATEGORY_COLOR = Object.freeze(Object.defineProperties({}, Object.fromEntries(Object.keys(CATEGORY_COLUMN).map((c) => [c, { get: () => COLUMN_COLOR[colourKey(c)], enumerable: true }]))));
|
|
36
|
+
// Where work happened (sessions, runs) and the change records themselves are not drawn: sources list the former, and a
|
|
37
|
+
// star on the affected objects shows the latter (owner 2026-09-17).
|
|
38
|
+
export const NOT_DRAWN = new Set(['Session', 'Run', 'Change']);
|
|
39
|
+
export const OBSERVED = ['Result', 'Review', 'Test'];
|
|
40
|
+
// The owner's words (D63): one group on top of the graph with its count, folded until opened; the server says which
|
|
41
|
+
// nodes are in it (`group`), the graph only lays it out (Spec §6.3).
|
|
42
|
+
export const OWNER_WORDS = "Owner's words";
|
|
43
|
+
const WORDS_GROUP_ID = 'group:owners-words';
|
|
44
|
+
// A node the server put in the group, or an element drawn for one (its category) or for the group itself.
|
|
45
|
+
const isWordsItem = (d) => d.group === OWNER_WORDS || d.category === OWNER_WORDS;
|
|
46
|
+
const HIDDEN_VALIDITY = new Set(['Replaced', 'Deferred', 'Abandoned']);
|
|
47
|
+
const REFERENCE_DETAIL = new Set(['Requirement', 'Design', 'Decision']);
|
|
48
|
+
const FOLDABLE = new Set(['Session', 'Run', 'Result', 'Review', 'Test']);
|
|
49
|
+
const isWork = (category) => category === 'Work item' || category === 'Plan';
|
|
50
|
+
/** A name that repeats its own identifier ("CKC-01 CKC-01 · …") shows it once. */
|
|
51
|
+
export const cleanName = (label) => { const s = String(label ?? ''); const m = /^(\S+)\s+\1(\s|$)/.exec(s); return m ? s.slice(m[1].length + 1) : s; };
|
|
52
|
+
|
|
53
|
+
// Corner marks (D53): one table gives the glyph, its colour and what the legend and the tooltip say, so the picture and
|
|
54
|
+
// its legend never say two things (the WorkflowKeeper rule). They sit on the object's corner and never repaint it. The
|
|
55
|
+
// colour is read from the palette in force each time it is asked for, so the marks keep their identity across themes
|
|
56
|
+
// (the legend and the List count by them) and the page colours them by class `mark-<key>` (styles.css).
|
|
57
|
+
const mark = (key, glyph, legend, desc) => ({ key, glyph, legend, desc, get color() { return MARK_COLOURS[key](palette); } });
|
|
58
|
+
export const MARKS = {
|
|
59
|
+
star: mark('star', '★', 'new or changed', 'new or changed since your last visit, or in the last days — the star buttons above choose which'),
|
|
60
|
+
flag: mark('flag', '⚑', 'flagged', 'a flag on the object: layer drift, suspected stale, scope question …; open it to see which'),
|
|
61
|
+
// A note on the object (D100: ❓ on the object in Graph and List). Highlighted while the note still needs you (it is
|
|
62
|
+
// in `Notes (attention)`, Spec §2.7), quiet otherwise; on a folded group, a stacked cell or a rolled band it stays lit
|
|
63
|
+
// with its count.
|
|
64
|
+
note: mark('note', '❓', 'note', 'a note hangs on it; open the object to read it'),
|
|
65
|
+
ask: mark('ask', '❓', 'note that needs you', 'a note on it still needs you: it asks for your decision or is worth discussing, and you have not answered it'),
|
|
66
|
+
pending: mark('pending', '⟳', 'Update pending', 'material behind it changed and the Keeper has not taken the change in yet'),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Which nodes get a star (owner 2026-09-17): what is new or changed. `visit` marks what appeared or changed since the
|
|
71
|
+
* owner's previous visit; `days` marks the last one to three days, choosing the longest window that still singles
|
|
72
|
+
* things out (a very active project gets one day).
|
|
73
|
+
*/
|
|
74
|
+
export function starredIds(nodes, mode, lastVisit, now = Date.now()) {
|
|
75
|
+
const drawn = nodes.filter((n) => !NOT_DRAWN.has(n.category) && n.validity !== 'Unjudged');
|
|
76
|
+
const touched = (n) => [n.changedAt || '', n.createdAt || ''].sort().pop();
|
|
77
|
+
const since = (iso) => new Set(drawn.filter((n) => touched(n) && touched(n) > iso).map((n) => n.id));
|
|
78
|
+
if (mode === 'visit') {
|
|
79
|
+
if (!lastVisit) return { ids: new Set(), note: 'Stars appear from your next visit: they mark what is new since the last time you opened this project.' };
|
|
80
|
+
const ids = since(lastVisit);
|
|
81
|
+
return { ids, note: ids.size ? `★ new or changed since your last visit (${new Date(lastVisit).toLocaleString(undefined, { hour12: false })})` : 'Nothing new since your last visit.' };
|
|
82
|
+
}
|
|
83
|
+
const limit = Math.max(5, Math.round(drawn.length * 0.2));
|
|
84
|
+
for (const days of [3, 2, 1]) {
|
|
85
|
+
const ids = since(new Date(now - days * 86_400_000).toISOString());
|
|
86
|
+
if (ids.size <= limit || days === 1) {
|
|
87
|
+
if (days === 1 && ids.size > drawn.length * 0.6) return { ids: new Set(), note: 'Almost everything changed within the last day, so no stars are shown.' };
|
|
88
|
+
return { ids, note: ids.size ? `★ new or changed in the last ${days === 1 ? 'day' : `${days} days`}` : `Nothing new in the last ${days} days.` };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { ids: new Set(), note: '' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What each column's Observed reality holds (D55; CKC-09 AC-15, AC-30), counted once for the graph's folders and the
|
|
96
|
+
* List's cells, so the two cannot disagree. A column is an area, or `null` for Project-wide; it holds the results,
|
|
97
|
+
* reviews and tests of its visible work. Results linked to no work sit in Project-wide as `unplaced`. Results of hidden
|
|
98
|
+
* work (replaced or deferred) appear only with `Show replaced & deferred`, like the work itself.
|
|
99
|
+
*/
|
|
100
|
+
export function observedCells(data, showReplaced = false, model = null) {
|
|
101
|
+
// The column of a work is its place on the story map (ui/placement.js): the Graph's folders and the List's blocks
|
|
102
|
+
// read the same model. A bare list of nodes (no relations) is placed by the nodes' own `areaId`.
|
|
103
|
+
const payload = Array.isArray(data) ? { nodes: data, relations: [] } : data;
|
|
104
|
+
const nodes = payload.nodes;
|
|
105
|
+
const M = model ?? placementOf(payload, { showReplaced });
|
|
106
|
+
const vis = (n) => !HIDDEN_VALIDITY.has(n.validity) || showReplaced || n.recentChange;
|
|
107
|
+
const areaIds = new Set(M.areas.map((a) => a.id));
|
|
108
|
+
const works = new Map(nodes.filter((n) => isWork(n.category)).map((n) => [n.id, n]));
|
|
109
|
+
const empty = () => ({ items: [], total: 0, Result: 0, Review: 0, Test: 0, flagged: 0, noted: 0, pending: 0, unplaced: 0 });
|
|
110
|
+
const cells = new Map([...M.areas.map((a) => [a.id, empty()]), [null, empty()]]);
|
|
111
|
+
const columnOf = (work) => { const a = M.place.get(work.id)?.area ?? work.areaId; return areaIds.has(a) ? a : null; };
|
|
112
|
+
for (const n of nodes) {
|
|
113
|
+
if (!OBSERVED.includes(n.category)) continue;
|
|
114
|
+
const work = n.parentId ? works.get(n.parentId) : undefined;
|
|
115
|
+
if (work && !vis(work)) continue;
|
|
116
|
+
const cell = cells.get(work ? columnOf(work) : null);
|
|
117
|
+
cell.items.push(n); cell.total++; cell[n.category]++;
|
|
118
|
+
if (!work) cell.unplaced++;
|
|
119
|
+
if (n.marks?.length) cell.flagged++;
|
|
120
|
+
if (n.noteCount) cell.noted++;
|
|
121
|
+
if (n.updatePending) cell.pending++;
|
|
122
|
+
}
|
|
123
|
+
return cells;
|
|
124
|
+
}
|
|
125
|
+
const plural = (k, word) => `${k} ${word}${k === 1 ? '' : 's'}`;
|
|
126
|
+
/** The one-line count of a cell, the same words on the folder and in the List. */
|
|
127
|
+
export function cellSummary(cell) {
|
|
128
|
+
return [cell.Result && plural(cell.Result, 'result'), cell.Review && plural(cell.Review, 'review'), cell.Test && plural(cell.Test, 'test')].filter(Boolean).join(' · ');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Text inside objects ──────────────────────────────────────────────────
|
|
132
|
+
const FONT = "'Segoe UI Variable Text','Segoe UI','Microsoft YaHei',sans-serif";
|
|
133
|
+
const measureCtx = typeof document !== 'undefined' ? document.createElement('canvas').getContext('2d') : null;
|
|
134
|
+
function measure(text, size, weight = 400) {
|
|
135
|
+
if (!measureCtx) return String(text).length * size * 0.6;
|
|
136
|
+
measureCtx.font = `${weight} ${size}px ${FONT}`;
|
|
137
|
+
return measureCtx.measureText(text).width;
|
|
138
|
+
}
|
|
139
|
+
// Chinese, Japanese and Korean text breaks between any two characters, so it fills the line it starts on instead of
|
|
140
|
+
// leaving "CKC-01 ·" alone on the first line.
|
|
141
|
+
const CJK = /[⺀-鿿가-豈--]/;
|
|
142
|
+
function wrapLines(text, size, weight, maxWidth) {
|
|
143
|
+
const lines = [];
|
|
144
|
+
for (const para of String(text).split('\n')) {
|
|
145
|
+
let line = '';
|
|
146
|
+
for (const word of para.split(' ')) {
|
|
147
|
+
const candidate = line ? `${line} ${word}` : word;
|
|
148
|
+
if (measure(candidate, size, weight) <= maxWidth) { line = candidate; continue; }
|
|
149
|
+
if (CJK.test(word) || measure(word, size, weight) > maxWidth) {
|
|
150
|
+
if (line) line += ' ';
|
|
151
|
+
for (const ch of word) { if (line.trim() && measure(line + ch, size, weight) > maxWidth) { lines.push(line.trimEnd()); line = ch; } else line += ch; }
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (line) lines.push(line);
|
|
155
|
+
line = word;
|
|
156
|
+
}
|
|
157
|
+
lines.push(line);
|
|
158
|
+
}
|
|
159
|
+
return lines;
|
|
160
|
+
}
|
|
161
|
+
/** At most `max` lines; the last one ends in an ellipsis when the name goes on. The full name is in the tooltip. */
|
|
162
|
+
function clampLines(text, size, weight, maxWidth, max) {
|
|
163
|
+
const lines = wrapLines(text, size, weight, maxWidth);
|
|
164
|
+
if (lines.length <= max) return lines;
|
|
165
|
+
let last = lines[max - 1];
|
|
166
|
+
while (last.length && measure(`${last}…`, size, weight) > maxWidth) last = last.slice(0, -1);
|
|
167
|
+
return [...lines.slice(0, max - 1), `${last}…`];
|
|
168
|
+
}
|
|
169
|
+
const xml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
170
|
+
// `fit`: the width the words are set to when they are a little too long for their line (see kindLine).
|
|
171
|
+
function svgText(x, y, str, o = {}) {
|
|
172
|
+
return `<text x="${x}" y="${y}" font-family="${xml(FONT)}" font-size="${o.size ?? TEXT.name}" font-weight="${o.weight ?? 400}" fill="${o.fill ?? palette.nodeText}"${o.anchor ? ` text-anchor="${o.anchor}"` : ''}${o.spacing ? ` letter-spacing="${o.spacing}"` : ''}${o.fit ? ` textLength="${o.fit.toFixed(1)}" lengthAdjust="spacingAndGlyphs"` : ''}${o.strike ? ' text-decoration="line-through"' : ''}${o.print ? ' data-print="small"' : ''}>${xml(str)}</text>`;
|
|
173
|
+
}
|
|
174
|
+
// The picture drawn over an object: its words, its corner marks and its link count. It is larger than the object by
|
|
175
|
+
// this much above (the marks sit on the top edge) and to the right (the link count), and drawn at twice the size so it
|
|
176
|
+
// stays sharp when zoomed in. A theme's material (a lip, a shadow) needs room below and to the left as well (`room`);
|
|
177
|
+
// the picture then says how far it reaches (`ox`, `oy`) and cytoscape places it by that. Room is never part of the
|
|
178
|
+
// object's bounds: fitting and the self-check see the object, its marks and its link count, as before.
|
|
179
|
+
const OVER = { top: 12, right: 38 };
|
|
180
|
+
function picture(w, h, body, room = { left: 0, bottom: 0 }) {
|
|
181
|
+
const W = w + OVER.right + room.left, H = h + OVER.top + room.bottom;
|
|
182
|
+
return { uri: `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${W * 2}" height="${H * 2}" viewBox="${-room.left} ${-OVER.top} ${W} ${H}">${body}</svg>`)}`, w: W, h: H, ox: -room.left, oy: -OVER.top };
|
|
183
|
+
}
|
|
184
|
+
/** A theme's material under an object's shape, drawn once per shape, size and colour until the theme changes. */
|
|
185
|
+
const materials = new Map();
|
|
186
|
+
function materialPicture(kind, w, h, colour) {
|
|
187
|
+
const m = materialOf(palette);
|
|
188
|
+
if (!m) return null;
|
|
189
|
+
const key = `${kind}|${w}|${h}|${colour}`;
|
|
190
|
+
if (!materials.has(key)) materials.set(key, picture(w, h, materialSvg(kind, w, h, m, colour), materialRoom(m)));
|
|
191
|
+
return materials.get(key);
|
|
192
|
+
}
|
|
193
|
+
// A corner mark stands on the object's top edge, 11px above it (OVER.top, and the gaps of the layout, leave that room)
|
|
194
|
+
// and 9px into it: the line of kind and progress starts below that, so a mark never covers a word.
|
|
195
|
+
const MARK_R = 10, MARK_STEP = 23;
|
|
196
|
+
function badge(x, y, mark) {
|
|
197
|
+
// A quiet ❓ (a note that does not need you now) stays on the object but steps back; the one that needs you is lit.
|
|
198
|
+
const lit = mark.key === 'ask';
|
|
199
|
+
return `<g${mark.key === 'note' ? ' opacity="0.55"' : ''}><circle cx="${x}" cy="${y}" r="${MARK_R}" fill="${tint(mark.color, lit ? 0.75 : 0.3, palette.chipBg)}" stroke="${mark.color}" stroke-width="${lit ? 2 : 1.2}"/>${svgText(x, y + 4.5, mark.glyph, { size: TEXT.count, weight: 700, fill: lit ? palette.nodeBg : mark.color, anchor: 'middle' })}</g>`;
|
|
200
|
+
}
|
|
201
|
+
/** A mark with its count on a folded group, a stacked cell or a rolled band: it stays lit while folded (D100). */
|
|
202
|
+
function countBadge(xRight, y, mark, count) {
|
|
203
|
+
const label = `${mark.glyph} ${count}`, w = measure(label, TEXT.count, 700) + 14;
|
|
204
|
+
const lit = mark.key === 'ask';
|
|
205
|
+
return { w, svg: `<g${mark.key === 'note' ? ' opacity="0.6"' : ''}><rect x="${xRight - w}" y="${y - 10}" width="${w}" height="20" rx="10" fill="${tint(mark.color, lit ? 0.75 : 0.3, palette.chipBg)}" stroke="${mark.color}" stroke-width="${lit ? 2 : 1.2}"/>${svgText(xRight - w / 2, y + 4.5, label, { size: TEXT.count, weight: 700, fill: lit ? palette.nodeBg : mark.color, anchor: 'middle' })}</g>` };
|
|
206
|
+
}
|
|
207
|
+
/** The count marks of what a fold holds: ❓ that need you, quiet ❓, flags. */
|
|
208
|
+
function foldMarks(m) {
|
|
209
|
+
return [m?.attention ? [MARKS.ask, m.attention] : null, m?.quiet ? [MARKS.note, m.quiet] : null, m?.flagged ? [MARKS.flag, m.flagged] : null].filter(Boolean);
|
|
210
|
+
}
|
|
211
|
+
function badgesRight(w, marks) {
|
|
212
|
+
let svg = '', xr = w - 8;
|
|
213
|
+
for (const [mk, k] of foldMarks(marks)) { const b = countBadge(xr, -1, mk, k); svg += b.svg; xr -= b.w + 4; }
|
|
214
|
+
return svg;
|
|
215
|
+
}
|
|
216
|
+
function linkChip(w, h, links) {
|
|
217
|
+
if (!links) return '';
|
|
218
|
+
const cx = w + 5, cy = h / 2, label = `↗${links}`, lw = measure(label, TEXT.count, 600) + 8;
|
|
219
|
+
return `<rect x="${cx - 2}" y="${cy - 10}" width="${lw}" height="20" rx="10" fill="${palette.chipBg}" stroke="${palette.chipBorder}"/>${svgText(cx - 2 + lw / 2, cy + 4.5, label, { size: TEXT.count, weight: 600, fill: palette.nodeMuted, anchor: 'middle' })}`;
|
|
220
|
+
}
|
|
221
|
+
/** The word `Inferred` on an object's top edge, from `x` (Spec §2.3: the Keeper's inference, not the material's). */
|
|
222
|
+
export const INFERRED_WORD = 'Inferred';
|
|
223
|
+
function inferredChip(x) {
|
|
224
|
+
const w = measure(INFERRED_WORD, TEXT.count, 600) + 12;
|
|
225
|
+
return `<g><rect x="${x}" y="-10" width="${w}" height="18" rx="4" fill="${palette.chipBg}" stroke="${palette.edgeOther}" stroke-width="1"/>${svgText(x + w / 2, 3.5, INFERRED_WORD, { size: TEXT.count, weight: 600, fill: palette.nodeMuted, anchor: 'middle' })}</g>`;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* The count of what is open on a work item — open send-backs, lit breakpoints, findings still open — on its top edge,
|
|
229
|
+
* left of the corner marks, whether its steps are folded or drawn (D75; owner 2026-10-01, E152). In the warning colour,
|
|
230
|
+
* with the sign, so the colour is not the only carrier. `xRight`: where the pill ends.
|
|
231
|
+
*/
|
|
232
|
+
export const OPEN_SIGN = '⚠';
|
|
233
|
+
function openBadge(xRight, count) {
|
|
234
|
+
const label = `${OPEN_SIGN} ${count}`, w = measure(label, TEXT.count, 700) + 14;
|
|
235
|
+
return `<g data-open="${count}"><rect x="${xRight - w}" y="-11" width="${w}" height="20" rx="10" fill="${tint(palette.danger, 0.3, palette.chipBg)}" stroke="${palette.danger}" stroke-width="1.6"/>${svgText(xRight - w / 2, 3.5, label, { size: TEXT.count, weight: 700, fill: palette.danger, anchor: 'middle' })}</g>`;
|
|
236
|
+
}
|
|
237
|
+
export function marksOf(n, starred) {
|
|
238
|
+
const out = [];
|
|
239
|
+
if (starred) out.push(MARKS.star);
|
|
240
|
+
if (n.marks?.length) out.push(MARKS.flag);
|
|
241
|
+
if (n.noteAttention || n.noteCount) out.push(n.noteAttention ? MARKS.ask : MARKS.note);
|
|
242
|
+
if (n.updatePending) out.push(MARKS.pending);
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
// Where the words sit in an object, px from its top at 100%: the line of kind and progress, the name's first line and
|
|
246
|
+
// the step to its second. The sizes of the words are TEXT (graph-fit.js); the sizes of the objects (SIZE) follow from these.
|
|
247
|
+
const LINE = { kind: 19, name: 37, step: 17, productName: 38, productStep: 18 };
|
|
248
|
+
/**
|
|
249
|
+
* Kind and validity on the left, progress on the right, on one line (Spec §6.3). When the two are too long for the line
|
|
250
|
+
* they are set narrower — by a tenth at most, the letters keep their height — and past that the kind gives way with an
|
|
251
|
+
* ellipsis. Progress is never cut; the tooltip has kind and validity in full.
|
|
252
|
+
*/
|
|
253
|
+
function kindLine(pad, y, w, kind, progress, color) {
|
|
254
|
+
const size = TEXT.kind, room = w - pad * 2 - (progress ? 8 : 0);
|
|
255
|
+
const pw = progress ? measure(progress, size) : 0;
|
|
256
|
+
let kw = measure(kind, size, 600);
|
|
257
|
+
const squeeze = Math.min(1, Math.max(0.9, room / (kw + pw)));
|
|
258
|
+
if ((kw + pw) * squeeze > room + 0.5) { kind = clampLines(kind, size, 600, room / squeeze - pw, 1)[0]; kw = measure(kind, size, 600); }
|
|
259
|
+
// The kind is written in the object's own colour, or — a theme that keeps colour to the edges — in its text colour.
|
|
260
|
+
const parts = [svgText(pad, y, kind, { size, weight: 600, fill: palette.nodeKind ?? color, fit: squeeze < 1 ? kw * squeeze : 0 })];
|
|
261
|
+
if (progress) parts.push(svgText(w - pad, y, progress, { size, fill: palette.nodeMuted, anchor: 'end', fit: squeeze < 1 ? pw * squeeze : 0 }));
|
|
262
|
+
return parts;
|
|
263
|
+
}
|
|
264
|
+
/** The picture over an object (exported for its test). */
|
|
265
|
+
export function objectImage(n, w, h, o) {
|
|
266
|
+
const key = colourKey(n.category);
|
|
267
|
+
const color = COLUMN_COLOR[key];
|
|
268
|
+
const pointed = key === 'plan' || key === 'work' || key === 'reality';
|
|
269
|
+
const small = FOLDABLE.has(n.category);
|
|
270
|
+
// The sides of a hexagon and of an oval lean in towards the top and bottom edge, so the words on them start further in:
|
|
271
|
+
// far enough that a second line of name, or `No established link`, clears the slanted edge.
|
|
272
|
+
const pad = small ? 24 : pointed ? 22 : 12;
|
|
273
|
+
const parts = [];
|
|
274
|
+
const validity = ['Replaced', 'Deferred', 'Abandoned', 'Proposed'].includes(n.validity) ? ` · ${n.validity.toUpperCase()}` : '';
|
|
275
|
+
const kind = n.category === 'Area' ? `AREA · ${areaWorkLine(o.areaWork)}` : n.category.toUpperCase();
|
|
276
|
+
const progress = isWork(n.category) && n.progress ? `${n.progress}${n.progress === 'Done' && n.acceptance ? ` · ${n.acceptance === 'Accepted' ? 'accepted' : 'not accepted'}` : ''}` : '';
|
|
277
|
+
parts.push(...kindLine(pad, LINE.kind, w, kind + validity, progress, color));
|
|
278
|
+
const big = n.category === 'Product';
|
|
279
|
+
const bold = big || n.category === 'Goal' || n.category === 'Area' || n.category === OWNER_WORDS;
|
|
280
|
+
const size = big ? TEXT.product : TEXT.name, weight = bold ? 600 : 400;
|
|
281
|
+
const first = big ? LINE.productName : LINE.name, step = big ? LINE.productStep : LINE.step;
|
|
282
|
+
const struck = n.validity === 'Replaced' || n.validity === 'Abandoned';
|
|
283
|
+
// A folded work with a process carries its four things on the second line (CKC-24); the name then takes one line.
|
|
284
|
+
const procLine = o.procLine && !n.noEstablishedLink ? o.procLine : null;
|
|
285
|
+
const lines = clampLines(cleanName(n.label), size, weight, w - pad * 2, small || n.noEstablishedLink || procLine ? 1 : 2);
|
|
286
|
+
lines.forEach((t, i) => parts.push(svgText(pad, first + i * step, t, { size, weight, strike: struck, fill: struck || n.validity === 'Deferred' ? palette.nodeMuted : palette.nodeText })));
|
|
287
|
+
if (procLine) parts.push(svgText(pad, first + step, clampLines(procLine, TEXT.count, 400, w - pad * 2, 1)[0], { size: TEXT.count, fill: palette.nodeMuted, print: true }));
|
|
288
|
+
if (n.noEstablishedLink) parts.push(svgText(pad, first + step, 'No established link', { size: TEXT.kind, fill: palette.danger }));
|
|
289
|
+
const marks = marksOf(n, o.starred);
|
|
290
|
+
const x0 = w - (pointed ? 33 : 16);
|
|
291
|
+
marks.forEach((m, i) => parts.push(badge(x0 - i * MARK_STEP, -1, m)));
|
|
292
|
+
if (o.open?.count) parts.push(openBadge(x0 - marks.length * MARK_STEP + MARK_R, o.open.count));
|
|
293
|
+
// An inferred object says so in words on its top edge, left of the corner marks (Spec §2.3): the frame stays solid,
|
|
294
|
+
// because a dashed frame is a work item's copy in another module it serves (owner, 2026-09-30).
|
|
295
|
+
if (n.basis === 'Inferred') parts.push(inferredChip(pointed ? 20 : 10));
|
|
296
|
+
if (o.links) {
|
|
297
|
+
const cx = w + 5, cy = h / 2, label = `↗${o.links}`, lw = measure(label, TEXT.count, 600) + 8;
|
|
298
|
+
parts.push(`<rect x="${cx - 2}" y="${cy - 10}" width="${lw}" height="20" rx="10" fill="${palette.chipBg}" stroke="${palette.chipBorder}"/>`, svgText(cx - 2 + lw / 2, cy + 4.5, label, { size: TEXT.count, weight: 600, fill: palette.nodeMuted, anchor: 'middle' }));
|
|
299
|
+
}
|
|
300
|
+
return picture(w, h, parts.join(''));
|
|
301
|
+
}
|
|
302
|
+
/** The shape an object is drawn in, for the material under it (graph-material.js): the same as its cytoscape shape. */
|
|
303
|
+
const shapeOf = (category) => { const k = colourKey(category); return k === 'intent' ? 'doc' : k === 'reality' ? 'ellipse' : 'hex'; };
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The picture of a process node (CKC-24, mockup graph-process-v0): two or three short lines — the step's kind and who
|
|
307
|
+
* did it, then what reality gave — drawn with the same machinery as every other object. `d.tones` names each line's
|
|
308
|
+
* tone: kind · muted · faint · danger · ok · accent. Returns the image fields the base node style reads.
|
|
309
|
+
*/
|
|
310
|
+
export function processImage(d) {
|
|
311
|
+
const toneColor = (t) => t === 'danger' ? palette.danger : t === 'ok' ? palette.diffAdded : t === 'accent' ? palette.questioned : t === 'kind' ? (palette.nodeKind ?? palette.work) : t === 'faint' ? palette.nodeFaint : palette.nodeMuted;
|
|
312
|
+
const w = d.w ?? PROC_SIZE.step[0], h = d.h ?? PROC_SIZE.step[1];
|
|
313
|
+
const pad = 14;
|
|
314
|
+
const parts = [];
|
|
315
|
+
String(d.rawLabel ?? '').split('\n').slice(0, 3).forEach((text, i) => {
|
|
316
|
+
const size = i === 0 ? TEXT.kind : TEXT.count;
|
|
317
|
+
parts.push(svgText(pad, 19 + i * 15, clampLines(text, size, i === 0 ? 600 : 400, w - pad * 2, 1)[0], { size, weight: i === 0 ? 600 : 400, fill: toneColor(d.tones?.[i] ?? 'muted') }));
|
|
318
|
+
});
|
|
319
|
+
const pic = picture(w, h, parts.join(''));
|
|
320
|
+
return { imgs: [pic.uri], imgWs: [pic.w], imgHs: [pic.h], imgOxs: [pic.ox], imgOys: [pic.oy] };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The process layer (CKC-24): k-process.js registers it; the graph asks it for the folded line of a work, the branches
|
|
325
|
+
* to draw, the works to alert, and whether a node passes the process filters. Without it (no data), nothing changes.
|
|
326
|
+
*/
|
|
327
|
+
let procLayer = null;
|
|
328
|
+
export function setProcessLayer(layer) { procLayer = layer; }
|
|
329
|
+
// A folded group is a small stack of cards with its count (D53): never a dashed box, which means a copy. An open
|
|
330
|
+
// group that stays on the picture (the owner's words) is one card: the heading of what is drawn beneath it.
|
|
331
|
+
function groupImage(label, count, w, h, open = false, marks = null, links = 0) {
|
|
332
|
+
const chip = open ? `${count}` : `+${count}`, cw = measure(chip, TEXT.count, 600) + 12;
|
|
333
|
+
const text = clampLines(label, TEXT.kind, 400, w - cw - 28, 1)[0];
|
|
334
|
+
// A group and a folder are drawn entirely by their picture, so the material goes in the same picture, under the card.
|
|
335
|
+
const m = materialOf(palette);
|
|
336
|
+
return picture(w, h, (open ? '' : `<rect x="5.5" y="-3.5" width="${w - 11}" height="${h - 1}" rx="6" fill="${palette.groupBack}" stroke="${palette.groupBackBorder}"/>`)
|
|
337
|
+
+ (m ? materialSvg('card', w, h, { ...m, bg: palette.groupBg }, palette.groupBorder) : '')
|
|
338
|
+
+ `<rect x="0.6" y="0.6" width="${w - 1.2}" height="${h - 1.2}" rx="6" fill="${m ? 'none' : palette.groupBg}" stroke="${palette.groupBorder}" stroke-width="1.2"/>`
|
|
339
|
+
+ `<rect x="10" y="${h / 2 - 10}" width="${cw}" height="20" rx="10" fill="${palette.groupChip}"/>` + svgText(10 + cw / 2, h / 2 + 4.5, chip, { size: TEXT.count, weight: 600, anchor: 'middle' })
|
|
340
|
+
+ svgText(17 + cw, h / 2 + 4.5, text, { size: TEXT.kind, fill: palette.nodeMuted }) + badgesRight(w, marks) + linkChip(w, h, links), materialRoom(m));
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* A card drawn by its picture, a few lines on it: an earlier generation's rolled band (its name and where its work
|
|
344
|
+
* went), the head of `Not in a plan`, the head of the cross-cutting column. `lines`: [text, tone] with tone kind ·
|
|
345
|
+
* text · muted · faint; `marks` as foldMarks, lit on the card's top edge.
|
|
346
|
+
*/
|
|
347
|
+
function cardImage(lines, w, h, { border = palette.groupBorder, marks = null } = {}) {
|
|
348
|
+
const m = materialOf(palette);
|
|
349
|
+
const tone = (t) => t === 'kind' ? (palette.nodeKind ?? border) : t === 'muted' ? palette.nodeMuted : t === 'faint' ? palette.nodeFaint : palette.nodeText;
|
|
350
|
+
return picture(w, h, (m ? materialSvg('card', w, h, { ...m, bg: palette.groupBg }, border) : '')
|
|
351
|
+
+ `<rect x="0.6" y="0.6" width="${w - 1.2}" height="${h - 1.2}" rx="7" fill="${m ? 'none' : palette.groupBg}" stroke="${border}" stroke-width="1.4"/>`
|
|
352
|
+
+ lines.map(([t, tn], i) => { const size = i === 0 ? TEXT.kind : TEXT.count, weight = i === 0 ? 600 : 400; return svgText(12, 19 + i * 16, clampLines(t, size, weight, w - 24 - (i === 0 ? 90 : 0), 1)[0], { size, weight, fill: tone(tn) }); }).join('')
|
|
353
|
+
+ badgesRight(w, marks), materialRoom(m));
|
|
354
|
+
}
|
|
355
|
+
/** Nothing to draw: a band's background carries no words (its head does). */
|
|
356
|
+
const EMPTY_PIC = { uri: `data:image/svg+xml;utf8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"></svg>')}` };
|
|
357
|
+
/** The words on a band's or the ring's background, a small picture at its top left. */
|
|
358
|
+
function titlePicture(text, colour) {
|
|
359
|
+
const w = Math.ceil(measure(text, TEXT.kind, 600)) + 6, h = 18;
|
|
360
|
+
return { uri: `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${w * 2}" height="${h * 2}" viewBox="0 0 ${w} ${h}">${svgText(2, 13, text, { size: TEXT.kind, weight: 600, fill: colour })}</svg>`)}`, w, h };
|
|
361
|
+
}
|
|
362
|
+
// One Observed reality folder per column (D55): what the List's cell in the same row holds, with the same numbers.
|
|
363
|
+
function folderImage(cell, w, h) {
|
|
364
|
+
const c = COLUMN_COLOR.reality, tw = 78, th = 8;
|
|
365
|
+
const path = `M1,${th + 4} V4 Q1,1 4,1 H${tw - 10} L${tw},${th} H${w - 4} Q${w - 1},${th} ${w - 1},${th + 4} V${h - 4} Q${w - 1},${h - 1} ${w - 4},${h - 1} H4 Q1,${h - 1} 1,${h - 4} Z`;
|
|
366
|
+
// Three lines under the tab: the heading with the total, what the total is made of, and what hangs on it. An empty
|
|
367
|
+
// folder has nothing for the third line, so its one sentence may run on to it; a long summary ends in an ellipsis
|
|
368
|
+
// (the tooltip and the List's cell have it in full).
|
|
369
|
+
const m = materialOf(palette);
|
|
370
|
+
const parts = [m ? materialSvg('folder', w, h, m, c) : '', `<path d="${path}" fill="${m ? 'none' : palette.nodeBg}" stroke="${c}" stroke-width="1.5"/>`,
|
|
371
|
+
svgText(12, 28, 'OBSERVED REALITY', { size: TEXT.kind, weight: 600, fill: palette.nodeKind ?? c }),
|
|
372
|
+
svgText(w - 12, 31, String(cell.total), { size: TEXT.total, weight: 700, anchor: 'end', fill: cell.total ? palette.nodeText : palette.nodeFaint }),
|
|
373
|
+
...(cell.total ? clampLines(cellSummary(cell), TEXT.kind, 400, w - 24, 1) : clampLines('No result or verification recorded yet', TEXT.kind, 400, w - 24, 2)).map((t, i) => svgText(12, 47 + i * 17, t, { size: TEXT.kind, fill: cell.total ? palette.nodeText : palette.nodeFaint }))];
|
|
374
|
+
let fx = 12;
|
|
375
|
+
if (cell.unplaced) { const s = `${cell.unplaced} not linked to any work`; parts.push(svgText(fx, 64, s, { size: TEXT.kind, fill: palette.nodeMuted })); fx += measure(s, TEXT.kind) + 10; }
|
|
376
|
+
for (const [mark, k] of [[MARKS.flag, cell.flagged], [MARKS.note, cell.noted], [MARKS.pending, cell.pending]]) {
|
|
377
|
+
if (!k) continue;
|
|
378
|
+
const s = `${mark.glyph} ${k}`;
|
|
379
|
+
parts.push(svgText(fx, 64, s, { size: TEXT.count, weight: 600, fill: mark.color }));
|
|
380
|
+
fx += measure(s, TEXT.count, 600) + 10;
|
|
381
|
+
}
|
|
382
|
+
return picture(w, h, parts.join(''), materialRoom(m));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ── Where objects go ─────────────────────────────────────────────────────
|
|
386
|
+
// Every column equally wide and every object of a level equally big (D53): the name no longer sets the size.
|
|
387
|
+
//
|
|
388
|
+
// How much of a project fits a window at a readable zoom is decided here (CKC-09 AC-39): the zoom floor is fixed by the
|
|
389
|
+
// size of the words (graph-fit.js), so the lever is how big an object and the gaps are against its words. An object is
|
|
390
|
+
// just big enough for its line of kind and progress and two lines of name at those sizes (LINE, kindLine), what stands
|
|
391
|
+
// in a column is as wide as the column, and a gap is only what has to go through it:
|
|
392
|
+
// colGap a link count beside an object (D54), and the dotted line of an inferred own area up the column's left;
|
|
393
|
+
// rowGap between the top levels: the bus of the structure lines (`bus` below the upper object) and, under it, the
|
|
394
|
+
// corner marks of the lower one, which stand 11px above it;
|
|
395
|
+
// vGap between objects in a column: the corner marks of the lower one;
|
|
396
|
+
// bodyGap under an area, folderGap above the row of folders, wordsGap more under the owner's words than vGap.
|
|
397
|
+
// At the floor a column takes (228 + 28) × 11/14 = 201px of the window and a row of work (60 + 12) × 11/14 = 57px. With
|
|
398
|
+
// the sizes before (11.5px names on 206 × 54, columns 244 apart) names reached 11px at zoom 0.96: 233px and 63px.
|
|
399
|
+
export const SIZE = { Product: [320, 62], heading: [228, 60], item: [228, 60], result: [200, 50], group: [228, 36], folder: [228, 72], genhead: [0, 44] };
|
|
400
|
+
/** Process nodes of increment K (CKC-24): a step of a work's branch, and an open send-back the branch points back from. */
|
|
401
|
+
export const PROC_SIZE = { step: [204, 54], suggest: [196, 48] };
|
|
402
|
+
// ringPad, ringTitle: the room inside the cross-cutting ring around what it holds, and its line of words on top;
|
|
403
|
+
// bandPad: the room inside a band above and below its cells; bandGap: between two bands.
|
|
404
|
+
export const LAYOUT = { colW: 228, colGap: 28, edge: 20, top: 16, rowGap: 24, bus: 12, bodyGap: 20, vGap: 12, folderGap: 20, wordsGap: 8, foot: 24, ringPad: 14, ringTitle: 24, bandPad: 14, bandGap: 10 };
|
|
405
|
+
const COL_W = LAYOUT.colW, COL_GAP = LAYOUT.colGap, EDGE = LAYOUT.edge, V_GAP = LAYOUT.vGap;
|
|
406
|
+
const PROGRESS_ORDER = { 'In progress': 0, 'Planned': 1, 'On hold': 2, 'Done': 3 };
|
|
407
|
+
const byName = (a, b) => String(a.label).localeCompare(String(b.label), undefined, { numeric: true });
|
|
408
|
+
/** How many work items a cell shows before the rest stack as `+n more` (Spec §6.3 "一格放不下时叠起来"). */
|
|
409
|
+
export const CELL_SHOWN = 3;
|
|
410
|
+
function sizeOf(d) {
|
|
411
|
+
if (d.kind === 'folder') return SIZE.folder;
|
|
412
|
+
if (d.kind === 'group') return SIZE.group;
|
|
413
|
+
if (d.kind === 'genhead' || d.kind === 'band' || d.kind === 'ring') return [d.w, d.h];
|
|
414
|
+
if (d.kind === 'bandhead' || d.kind === 'colhead') return SIZE.item;
|
|
415
|
+
if (d.kind === 'proc-step' || d.kind === 'proc-suggest') return [d.w ?? PROC_SIZE.step[0], d.h ?? PROC_SIZE.step[1]];
|
|
416
|
+
if (d.category === 'Product') return SIZE.Product;
|
|
417
|
+
// Spec §6.3: the owner's words, goals and areas are one level and one size.
|
|
418
|
+
if (d.category === 'Goal' || d.category === 'Area' || d.category === OWNER_WORDS) return SIZE.heading;
|
|
419
|
+
if (FOLDABLE.has(d.category)) return SIZE.result;
|
|
420
|
+
return SIZE.item;
|
|
421
|
+
}
|
|
422
|
+
/** The width of the story map for so many areas: the band heads, one column per area, the cross-cutting column. */
|
|
423
|
+
export const storyWidth = (areaCount) => EDGE * 2 + (areaCount + 2) * COL_W + (areaCount + 1) * COL_GAP;
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The story map (D100; Spec §6.3 "默认可见"): from the top, the owner's words, the product and the goals; the areas as
|
|
427
|
+
* columns, each with its requirements, designs and decisions folded at its top; the cross-cutting ring under that row;
|
|
428
|
+
* then the bands — the earlier generations rolled, each current plan, `Not in a plan` — each work in its (area × plan)
|
|
429
|
+
* cell; the Observed reality folders below every band. Left of the columns is the column of the bands' heads (the plan
|
|
430
|
+
* itself, its execution decisions); right of them the cross-cutting column (no one area).
|
|
431
|
+
*
|
|
432
|
+
* Each item says where it goes by `slot` (the render puts it there from ui/placement.js): { zone, col, band, bi, after,
|
|
433
|
+
* indent }. zone: words · product · goal · area · colhead · intent · ring · genhead · head · cell · folder. An item
|
|
434
|
+
* with `after` stands right under that item in the same place (a result under its work, a process step under its work).
|
|
435
|
+
* An item without a slot (a test's made-up project) is placed by its category and `areaId`, its work in `Not in a plan`.
|
|
436
|
+
* Returns each item's box (top-left, size, column: -1 for what spans the columns), the columns, the bands and the ring
|
|
437
|
+
* as boxes, and the size of the whole; null when there are no areas to build columns from.
|
|
438
|
+
*/
|
|
439
|
+
export function storyLayout(items) {
|
|
440
|
+
const areas = items.filter((d) => d.category === 'Area' && !d.kind);
|
|
441
|
+
if (areas.length === 0) return null;
|
|
442
|
+
// The columns keep the order the areas come in (the render gives them in the model's order, by name).
|
|
443
|
+
const keys = [...areas.map((a) => a.id), null];
|
|
444
|
+
const A = areas.length;
|
|
445
|
+
const headX = EDGE;
|
|
446
|
+
const colX = keys.map((_, i) => EDGE + (i + 1) * (COL_W + COL_GAP));
|
|
447
|
+
const width = storyWidth(A);
|
|
448
|
+
const colOfArea = (id) => { const i = keys.indexOf(id ?? null); return i >= 0 ? i : A; };
|
|
449
|
+
const slotOf = (d) => {
|
|
450
|
+
if (d.slot) return d.slot;
|
|
451
|
+
if (d.kind === 'folder') return { zone: 'folder', col: colOfArea(d.folder) };
|
|
452
|
+
if (d.category === 'Product') return { zone: 'product' };
|
|
453
|
+
if (d.category === 'Goal') return { zone: 'goal' };
|
|
454
|
+
if (d.category === 'Area') return { zone: 'area', col: colOfArea(d.id) };
|
|
455
|
+
if (isWordsItem(d)) return { zone: 'words' };
|
|
456
|
+
return { zone: 'cell', band: NO_PLAN, bi: 0, col: d.kind === 'group' ? A : colOfArea(d.areaId) };
|
|
457
|
+
};
|
|
458
|
+
const box = new Map();
|
|
459
|
+
const put = (d, x, top, col) => { const [w, h] = sizeOf(d); box.set(d.id, { x, y: top, w, h, col }); return h; };
|
|
460
|
+
const byZone = new Map();
|
|
461
|
+
for (const d of items) { const s = slotOf(d); if (!byZone.has(s.zone)) byZone.set(s.zone, []); byZone.get(s.zone).push({ d, s }); }
|
|
462
|
+
const zone = (z) => byZone.get(z) ?? [];
|
|
463
|
+
/** A place's items in order: each followed by the items that stand right under it (by `after`, in `seq` order). */
|
|
464
|
+
const ordered = (list) => {
|
|
465
|
+
const main = list.filter((x) => !x.s.after || !list.some((y) => y.d.id === x.s.after));
|
|
466
|
+
const out = [];
|
|
467
|
+
const follow = (id) => list.filter((x) => x.s.after === id).sort((a, b) => (a.d.seq ?? 0) - (b.d.seq ?? 0));
|
|
468
|
+
const add = (x) => { out.push(x); for (const f of follow(x.d.id)) add(f); };
|
|
469
|
+
for (const x of main) add(x);
|
|
470
|
+
return out;
|
|
471
|
+
};
|
|
472
|
+
/** Stacks a place's items from `top` in the column at `x`; returns the bottom of the last. */
|
|
473
|
+
const stack = (list, x, top, col) => {
|
|
474
|
+
let y = top, bottom = top - V_GAP;
|
|
475
|
+
for (const { d, s } of ordered(list)) { const h = put(d, x + (s.indent ?? 0), y, col); bottom = y + h; y = bottom + V_GAP; }
|
|
476
|
+
return bottom;
|
|
477
|
+
};
|
|
478
|
+
const spanX0 = colX[0], spanX1 = colX[A] + COL_W;
|
|
479
|
+
|
|
480
|
+
// The owner's words above everything (Spec §6.3, D63): their card, and when open the words in rows, one per column.
|
|
481
|
+
let y = LAYOUT.top;
|
|
482
|
+
let band = -1;
|
|
483
|
+
const wordsHead = zone('words').filter((x) => x.d.kind === 'group');
|
|
484
|
+
const words = zone('words').filter((x) => x.d.kind !== 'group');
|
|
485
|
+
if (wordsHead.length || words.length) {
|
|
486
|
+
for (const { d } of wordsHead) y += put(d, (spanX0 + spanX1) / 2 - sizeOf(d)[0] / 2, y, -1) + V_GAP;
|
|
487
|
+
band = Math.ceil(words.length / keys.length);
|
|
488
|
+
words.forEach(({ d }, i) => put(d, colX[i % keys.length], y + Math.floor(i / keys.length) * (SIZE.heading[1] + V_GAP), -1));
|
|
489
|
+
y += band * (SIZE.heading[1] + V_GAP) + LAYOUT.wordsGap;
|
|
490
|
+
}
|
|
491
|
+
const rows = { product: y };
|
|
492
|
+
for (const { d } of zone('product')) put(d, (spanX0 + spanX1) / 2 - sizeOf(d)[0] / 2, y, -1);
|
|
493
|
+
rows.goal = zone('product').length ? y + SIZE.Product[1] + LAYOUT.rowGap : y;
|
|
494
|
+
const goals = zone('goal');
|
|
495
|
+
goals.forEach(({ d }, i) => put(d, spanX0 + (spanX1 - spanX0) * ((i + 0.5) / goals.length) - sizeOf(d)[0] / 2, rows.goal, -1));
|
|
496
|
+
rows.area = goals.length ? rows.goal + SIZE.heading[1] + LAYOUT.rowGap : rows.goal;
|
|
497
|
+
for (const { d, s } of [...zone('area'), ...zone('colhead')]) put(d, colX[s.col ?? colOfArea(d.id)], rows.area, s.col ?? colOfArea(d.id));
|
|
498
|
+
rows.body = rows.area + SIZE.heading[1] + LAYOUT.bodyGap;
|
|
499
|
+
|
|
500
|
+
// Each column's requirements, designs and decisions, folded at its top.
|
|
501
|
+
let bottom = rows.body - LAYOUT.bodyGap;
|
|
502
|
+
for (let c = 0; c <= A; c++) bottom = Math.max(bottom, stack(zone('intent').filter((x) => x.s.col === c), colX[c], rows.body, c));
|
|
503
|
+
|
|
504
|
+
// The cross-cutting ring: under the requirement row, above every band; its items across the columns, row by row.
|
|
505
|
+
let ring = null;
|
|
506
|
+
const inRing = ordered(zone('ring'));
|
|
507
|
+
if (inRing.length) {
|
|
508
|
+
const top = bottom + LAYOUT.rowGap;
|
|
509
|
+
let rowTop = top + LAYOUT.ringTitle, rowH = 0, i = 0;
|
|
510
|
+
for (const { d } of inRing) {
|
|
511
|
+
if (i === keys.length) { rowTop += rowH + V_GAP; rowH = 0; i = 0; }
|
|
512
|
+
rowH = Math.max(rowH, put(d, colX[i], rowTop, i));
|
|
513
|
+
i++;
|
|
514
|
+
}
|
|
515
|
+
const ringBottom = rowTop + rowH + LAYOUT.ringPad;
|
|
516
|
+
ring = { x: spanX0 - LAYOUT.ringPad, y: top, w: spanX1 - spanX0 + 2 * LAYOUT.ringPad, h: ringBottom - top };
|
|
517
|
+
bottom = ringBottom;
|
|
518
|
+
}
|
|
519
|
+
rows.bands = bottom + LAYOUT.rowGap;
|
|
520
|
+
|
|
521
|
+
// The bands, in their order: the earlier generations, the current plans, `Not in a plan`.
|
|
522
|
+
const bandItems = new Map();
|
|
523
|
+
for (const z of ['genhead', 'head', 'cell']) for (const x of zone(z)) { const k = x.s.band; if (!bandItems.has(k)) bandItems.set(k, { bi: x.s.bi ?? 0, list: [] }); bandItems.get(k).list.push(x); }
|
|
524
|
+
const bands = [];
|
|
525
|
+
y = rows.bands;
|
|
526
|
+
for (const [key, { bi, list }] of [...bandItems].sort((a, b) => a[1].bi - b[1].bi)) {
|
|
527
|
+
const top = y;
|
|
528
|
+
let inner = top + LAYOUT.bandPad;
|
|
529
|
+
for (const { d } of list.filter((x) => x.s.zone === 'genhead')) inner += put(d, headX, inner, -1) + V_GAP;
|
|
530
|
+
let low = inner - V_GAP;
|
|
531
|
+
low = Math.max(low, stack(list.filter((x) => x.s.zone === 'head'), headX, inner, -1));
|
|
532
|
+
for (let c = 0; c <= A; c++) low = Math.max(low, stack(list.filter((x) => x.s.zone === 'cell' && x.s.col === c), colX[c], inner, c));
|
|
533
|
+
const h = low + LAYOUT.bandPad - top;
|
|
534
|
+
bands.push({ key, kind: list[0]?.s.kind ?? null, x: EDGE / 2, y: top, w: width - EDGE, h });
|
|
535
|
+
y = top + h + LAYOUT.bandGap;
|
|
536
|
+
}
|
|
537
|
+
const folderY = (bands.length ? y - LAYOUT.bandGap : rows.bands - LAYOUT.rowGap) + LAYOUT.folderGap;
|
|
538
|
+
for (const { d, s } of zone('folder')) put(d, colX[s.col], folderY, s.col);
|
|
539
|
+
return { box, keys, colX, headX, width, rows, band, ring, bands, folderY, height: folderY + SIZE.folder[1] + LAYOUT.foot };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
// ── Readability self-check (D46; CKC-09 AC-26, AC-27) ────────────────────
|
|
544
|
+
/**
|
|
545
|
+
* Reads the picture as it is drawn and reports what a machine can judge: objects on top of each other, lines running
|
|
546
|
+
* behind objects they do not connect, a picture that cannot be read at any size that fits the window, and many lines
|
|
547
|
+
* bunched into one object. It never changes what is drawn; the owner is told where it is hard to read and offered a
|
|
548
|
+
* way to look at it (focus a path, another scale, a part of the picture at a readable size).
|
|
549
|
+
*
|
|
550
|
+
* `nodes`: { id, label, x, y, w, h } (centre and size; `top` and `right` when something stands out of the object: its
|
|
551
|
+
* corner marks, its link count); `edges`: { id, source, target, points? } where `points` is the line as drawn (a
|
|
552
|
+
* polyline from source to target); without it the straight line between the centres is used.
|
|
553
|
+
* `viewport`: { w, h } in pixels.
|
|
554
|
+
*/
|
|
555
|
+
// The zoom no view of the graph's own choosing goes below (graph-fit.js): a name is then as small as the interface's
|
|
556
|
+
// smallest body text, and no smaller. "Too big to read at once" is judged against it, by the sum the viewport itself
|
|
557
|
+
// uses — on the view the graph would open on, whatever view the owner has chosen since (they may zoom out further).
|
|
558
|
+
export const READABLE_ZOOM = ZOOM_FLOOR;
|
|
559
|
+
export function readabilityReport(nodes, edges, viewport, fitOpts = {}) {
|
|
560
|
+
const shrink = 2; // boxes that only touch are not "on top of each other"
|
|
561
|
+
const box = new Map(nodes.map((n) => [n.id, { ...n, x1: n.x - n.w / 2 + shrink, x2: n.x + n.w / 2 - shrink, y1: n.y - n.h / 2 + shrink, y2: n.y + n.h / 2 - shrink }]));
|
|
562
|
+
const boxes = [...box.values()].sort((a, b) => a.x1 - b.x1);
|
|
563
|
+
const issues = [];
|
|
564
|
+
|
|
565
|
+
// Objects on top of each other: sweep along x, compare only boxes whose x ranges meet.
|
|
566
|
+
const overlapping = new Set();
|
|
567
|
+
const pairs = [];
|
|
568
|
+
for (let i = 0; i < boxes.length; i++) {
|
|
569
|
+
const a = boxes[i];
|
|
570
|
+
for (let j = i + 1; j < boxes.length && boxes[j].x1 < a.x2; j++) {
|
|
571
|
+
const b = boxes[j];
|
|
572
|
+
if (b.y1 < a.y2 && a.y1 < b.y2) { overlapping.add(a.id); overlapping.add(b.id); pairs.push([a.id, b.id]); }
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (pairs.length) issues.push({ kind: 'overlap', count: overlapping.size, ids: [...overlapping], pairs, text: `${overlapping.size} objects sit on top of each other in ${pairs.length} place${pairs.length === 1 ? '' : 's'}` });
|
|
576
|
+
|
|
577
|
+
// Lines behind objects they do not connect: every segment of the line as drawn, against every other box.
|
|
578
|
+
const crosses = (x1, y1, x2, y2, b) => {
|
|
579
|
+
let t0 = 0, t1 = 1;
|
|
580
|
+
const dx = x2 - x1, dy = y2 - y1;
|
|
581
|
+
for (const [pp, q] of [[-dx, x1 - b.x1], [dx, b.x2 - x1], [-dy, y1 - b.y1], [dy, b.y2 - y1]]) {
|
|
582
|
+
if (pp === 0) { if (q < 0) return false; continue; }
|
|
583
|
+
const r = q / pp;
|
|
584
|
+
if (pp < 0) { if (r > t1) return false; if (r > t0) t0 = r; } else { if (r < t0) return false; if (r < t1) t1 = r; }
|
|
585
|
+
}
|
|
586
|
+
return t0 <= t1;
|
|
587
|
+
};
|
|
588
|
+
const behind = [];
|
|
589
|
+
for (const e of edges) {
|
|
590
|
+
const a = box.get(e.source), b = box.get(e.target);
|
|
591
|
+
if (!a || !b || a === b) continue;
|
|
592
|
+
const pts = e.points?.length >= 2 ? e.points : [{ x: a.x, y: a.y }, { x: b.x, y: b.y }];
|
|
593
|
+
const hit = new Set();
|
|
594
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
595
|
+
const p = pts[i], q = pts[i + 1];
|
|
596
|
+
const minX = Math.min(p.x, q.x), maxX = Math.max(p.x, q.x), minY = Math.min(p.y, q.y), maxY = Math.max(p.y, q.y);
|
|
597
|
+
for (const o of boxes) {
|
|
598
|
+
if (o.x1 > maxX) break;
|
|
599
|
+
if (o.id === e.source || o.id === e.target || o.x2 < minX || o.y2 < minY || o.y1 > maxY) continue;
|
|
600
|
+
if (crosses(p.x, p.y, q.x, q.y, o)) hit.add(o.id);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (hit.size) behind.push({ edge: e.id, source: e.source, target: e.target, hidden: [...hit] });
|
|
604
|
+
}
|
|
605
|
+
if (behind.length) {
|
|
606
|
+
behind.sort((x, y) => y.hidden.length - x.hidden.length);
|
|
607
|
+
issues.push({ kind: 'behind', count: behind.length, ids: [...new Set(behind.flatMap((x) => [x.source, x.target]))], worst: behind.slice(0, 8), text: `${behind.length} line${behind.length === 1 ? '' : 's'} of ${edges.length} run behind objects they do not connect` });
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Too big to read at once: fitted to the window its names would be smaller than the smallest body text, so it is
|
|
611
|
+
// shown at the zoom floor instead and the rest is reached by dragging. Said exactly when the view the graph opens on
|
|
612
|
+
// does not hold the whole picture (fitView is what the viewport uses), so the two cannot disagree; nothing is drawn
|
|
613
|
+
// smaller or left out to make this go away (D46).
|
|
614
|
+
if (nodes.length) {
|
|
615
|
+
const v = fitView(pictureBox(nodes), viewport, fitOpts); // the same room kept clear as the viewport keeps
|
|
616
|
+
if (!v.fits) issues.push({ kind: 'size', count: boxes.length, ids: [], fitZoom: v.fitZoom, floor: v.zoom, across: v.across, down: v.down, text: `Fitted to the window, names would be ${(TEXT.name * v.fitZoom).toFixed(1)}px, under the ${MIN_TEXT_PX}px of the smallest text here; kept readable, the picture is ${v.across.toFixed(1)} × ${v.down.toFixed(1)} windows, so it opens on its top left and the rest is reached by dragging` });
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Lines bunched into one object.
|
|
620
|
+
const degree = new Map();
|
|
621
|
+
for (const e of edges) for (const id of [e.source, e.target]) degree.set(id, (degree.get(id) ?? 0) + 1);
|
|
622
|
+
const hubs = [...degree].filter(([id, d]) => d >= 20 && box.has(id)).sort((a, b) => b[1] - a[1]);
|
|
623
|
+
if (hubs.length) issues.push({ kind: 'bundle', count: hubs.length, ids: hubs.map(([id]) => id), hubs: hubs.map(([id, d]) => ({ id, degree: d })), text: `${hubs.length} object${hubs.length === 1 ? '' : 's'} with 20 or more lines meeting on them` });
|
|
624
|
+
|
|
625
|
+
return { issues, nodes: boxes.length, edges: edges.length };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ── The graph ────────────────────────────────────────────────────────────
|
|
629
|
+
let registered = false;
|
|
630
|
+
function ensureRegistered() {
|
|
631
|
+
if (registered) return;
|
|
632
|
+
if (typeof cytoscape === 'undefined') throw new Error('cytoscape is not loaded');
|
|
633
|
+
if (typeof cytoscapeDagre !== 'undefined') cytoscape.use(cytoscapeDagre);
|
|
634
|
+
registered = true;
|
|
635
|
+
}
|
|
636
|
+
const DOC_POLYGON = '-1 -1 0.72 -1 1 -0.62 1 1 -1 1'; // a document with a folded top-right corner (-1..1)
|
|
637
|
+
const HEX_POLYGON = '-0.8 -1 0.8 -1 1 0 0.8 1 -0.8 1 -1 0'; // a long hexagon whose points stay the same size
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* A relation's part in the picture (D54). `own`: work serving the area whose column it stands in — the column says it,
|
|
641
|
+
* so no line, except a dotted one when the Keeper inferred it. `structure`: goal → product, area → goal, and project-wide
|
|
642
|
+
* plans → product — right-angled lines along shared buses. `other`: everything else — a count beside each end, drawn on
|
|
643
|
+
* hover, on selection, or all at once with the switch.
|
|
644
|
+
*/
|
|
645
|
+
function relationPart(r, from, to, M) {
|
|
646
|
+
if (!from || !to) return 'other';
|
|
647
|
+
if (from.category === 'Goal' && to.category === 'Product') return 'structure';
|
|
648
|
+
if (from.category === 'Area' && to.category === 'Goal') return 'structure';
|
|
649
|
+
const pf = M?.place.get(from.id), areaOf = (p) => p?.area ?? (p?.zone === 'intent' ? p.area : null);
|
|
650
|
+
// The story map says it where the object stands (D54, D100): work in its area's column serves that area, work in its
|
|
651
|
+
// plan's band belongs to that plan, a decision or requirement at a column's top refines that area, an execution
|
|
652
|
+
// decision in a plan's band refines that plan, and everything stands under the product.
|
|
653
|
+
if (to.category === 'Area' && (r.type === 'serves' || r.type === 'refines') && areaOf(pf) === to.id) return 'own';
|
|
654
|
+
// A work item serving several areas stands in each of them, dashed outside its own (owner, 2026-09-30): the copy in
|
|
655
|
+
// that column says it, so no line either.
|
|
656
|
+
if (to.category === 'Area' && r.type === 'serves' && pf?.areas?.includes(to.id)) return 'implied';
|
|
657
|
+
if (to.category === 'Plan' && (r.type === 'serves' || r.type === 'refines') && pf?.band === to.id) return 'implied';
|
|
658
|
+
if ((to.category === 'Product' || to.category === 'Goal') && r.type === 'refines') return 'implied';
|
|
659
|
+
if (from.category === 'Plan' && (to.category === 'Product' || to.category === 'Goal')) return 'implied';
|
|
660
|
+
// A work serving a requirement at the top of its own column: the column says it too.
|
|
661
|
+
if (r.type === 'serves' && isWork(from.category) && pf?.area && M?.place.get(to.id)?.zone === 'intent' && (pf.areas ?? [pf.area]).includes(M.place.get(to.id).area)) return 'implied';
|
|
662
|
+
return 'other';
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* The stylesheet, from a palette (graph-palette.js): the same rules for every theme, the colours from the theme in
|
|
667
|
+
* force. An object carries up to two pictures: its words, marks and link count over everything (containment `over`),
|
|
668
|
+
* and — when the theme lends material — the card's material under cytoscape's own border (`inside`), so the coloured
|
|
669
|
+
* edge and the selection ring stay on top of it.
|
|
670
|
+
*/
|
|
671
|
+
/** How strong a work item's copy is drawn against the solid one (1): at rest, selected, pointed at. */
|
|
672
|
+
export const COPY_OPACITY = Object.freeze({ rest: 0.5, selected: 0.9, hover: 0.95 });
|
|
673
|
+
/**
|
|
674
|
+
* A copy at rest is a pale ghost of the solid card (owner, 2026-10-01, E153: 「他是暗的,不是淡色一点,背景的透明度高一点呢」):
|
|
675
|
+
* the card keeps its own fill, a little let through, and its words, material and frame fade. Fading the whole card —
|
|
676
|
+
* fill and all, as it was — let the dark ground through a light card, and it read dark grey on the dark themes. On a
|
|
677
|
+
* theme whose cards are dark the two look the same.
|
|
678
|
+
*/
|
|
679
|
+
export const COPY_GHOST = Object.freeze({ fill: 0.9, words: COPY_OPACITY.rest, material: 0.5, frame: 0.6 });
|
|
680
|
+
export function graphStyle(p) {
|
|
681
|
+
return [
|
|
682
|
+
{ selector: 'node', style: {
|
|
683
|
+
'shape': 'round-rectangle', 'background-color': p.nodeBg, 'border-width': 1.5, 'border-color': 'data(color)', 'label': '', 'width': 'data(w)', 'height': 'data(h)',
|
|
684
|
+
'background-image': 'data(imgs)', 'background-width': 'data(imgWs)', 'background-height': 'data(imgHs)', 'background-position-x': ['0%', '0%'], 'background-position-y': ['0%', '0%'], 'background-offset-x': 'data(imgOxs)', 'background-offset-y': 'data(imgOys)',
|
|
685
|
+
'background-clip': ['none', 'none'], 'background-image-containment': ['over', 'inside'], 'bounds-expansion': OVER.right, 'background-image-smoothing': ['yes', 'yes'], 'background-image-opacity': [1, 1],
|
|
686
|
+
} },
|
|
687
|
+
{ selector: 'node.col-intent', style: { 'shape': 'polygon', 'shape-polygon-points': DOC_POLYGON } },
|
|
688
|
+
{ selector: 'node.col-work', style: { 'shape': 'polygon', 'shape-polygon-points': HEX_POLYGON } },
|
|
689
|
+
{ selector: 'node.col-reality', style: { 'shape': 'ellipse' } },
|
|
690
|
+
{ selector: 'node[category = "Product"]', style: { 'border-width': 2.2 } },
|
|
691
|
+
// A group and a folder are drawn entirely by their picture (stacked cards, a folder); the node is only the place to click.
|
|
692
|
+
{ selector: 'node.group, node.folder, node.card', style: { 'background-opacity': 0, 'border-width': 0 } },
|
|
693
|
+
// The story map's bands and its cross-cutting ring (D100): backgrounds under everything, never in the way of a click.
|
|
694
|
+
// The ring is the one drawing that rings a group of objects (Spec §6.3), a solid line with round corners; a band is
|
|
695
|
+
// a soft strip across the columns. Neither is dashed: a dashed frame means a work item's copy.
|
|
696
|
+
{ selector: 'node.band, node.ring', style: {
|
|
697
|
+
'shape': 'round-rectangle', 'events': 'no', 'z-compound-depth': 'bottom', 'z-index': 0, 'border-width': 1, 'background-opacity': 0.07, 'border-opacity': 0.5,
|
|
698
|
+
'background-image': 'data(imgs)', 'background-width': 'data(imgWs)', 'background-height': 'data(imgHs)', 'background-position-x': 0, 'background-position-y': 0,
|
|
699
|
+
'background-offset-x': 36, 'background-offset-y': 5, 'background-image-containment': 'inside', 'background-clip': 'node', 'bounds-expansion': 0,
|
|
700
|
+
} },
|
|
701
|
+
{ selector: 'node.band', style: { 'background-color': p.groupBorder, 'border-color': p.groupBorder, 'corner-radius': 10 } },
|
|
702
|
+
{ selector: 'node.band.plan', style: { 'background-color': p.plan, 'border-color': p.plan } },
|
|
703
|
+
{ selector: 'node.band.none', style: { 'background-opacity': 0.03 } },
|
|
704
|
+
{ selector: 'node.ring', style: { 'background-color': p.intent, 'border-color': p.intent, 'border-width': 1.6, 'border-opacity': 0.8, 'corner-radius': 34 } },
|
|
705
|
+
// A dashed frame is a work item's copy in another module it serves, its main one solid elsewhere (owner, 2026-09-30;
|
|
706
|
+
// Spec §2.3, §6.3), and nothing else. An inferred object is solid with the word `Inferred` on it (objectImage); an
|
|
707
|
+
// inferred relation is a dotted line (below).
|
|
708
|
+
// The whole copy is fainter than the solid one — words, material and frame (owner, 2026-10-01, E152: 「实现框和虚线框好像
|
|
709
|
+
// 看不出区别…要么整个变成透明一点的卡,只是边缘虚线区别不太大」): the dashes alone did not tell them apart at the zoom the
|
|
710
|
+
// picture opens on. How faint is set further down, after the scales' own (COPY_GHOST, COPY_OPACITY).
|
|
711
|
+
{ selector: 'node.copy', style: { 'border-style': 'dashed', 'border-width': 1.6 } },
|
|
712
|
+
{ selector: 'node.copy-sel', style: { 'border-color': p.selection, 'border-width': 2.5 } },
|
|
713
|
+
{ selector: 'node.replaced', style: { 'border-color': p.replaced } },
|
|
714
|
+
{ selector: 'node.faded', style: { 'opacity': 0.18 } },
|
|
715
|
+
// Selection and flash are white, so a selected node never looks like one of the kinds (owner 2026-09-17).
|
|
716
|
+
{ selector: 'node:selected', style: { 'border-color': p.selection, 'border-width': 2.5, 'outline-color': p.selection, 'outline-width': 4, 'outline-opacity': 0.22, 'z-index': 10 } },
|
|
717
|
+
// The mark `Show on graph` and `Show affected` leave on an object. The focus colour, not a kind colour, and drawn
|
|
718
|
+
// outside the shape so it stays obvious at the zoom floor on every theme — a thin translucent outline was not.
|
|
719
|
+
{ selector: 'node.hit', style: {
|
|
720
|
+
'underlay-color': p.focus, 'underlay-padding': 16, 'underlay-opacity': 0.92,
|
|
721
|
+
'outline-color': p.focus, 'outline-width': 8, 'outline-opacity': 1, 'outline-offset': 3,
|
|
722
|
+
'border-color': p.focus, 'border-width': 3.5, 'z-index': 45,
|
|
723
|
+
} },
|
|
724
|
+
// In the Work scale the focused node is the one the owner is reading from; everything on its path stays lit and
|
|
725
|
+
// everything else fades. The focus is a ring, not a bigger node, so its neighbours stay where they are.
|
|
726
|
+
{ selector: 'node.near', style: { 'opacity': 0.78 } },
|
|
727
|
+
// A copy is a pale ghost wherever it would otherwise be drawn at full strength, near a focus too; pointed at, or
|
|
728
|
+
// selected (its work item is), it comes forward enough to read, and stays dashed. Faded by a focus it fades with
|
|
729
|
+
// the rest.
|
|
730
|
+
{ selector: 'node.copy', style: { 'opacity': 1, 'background-opacity': COPY_GHOST.fill, 'background-image-opacity': [COPY_GHOST.words, COPY_GHOST.material], 'border-opacity': COPY_GHOST.frame } },
|
|
731
|
+
{ selector: 'node.copy.faded', style: { 'opacity': 0.12 } },
|
|
732
|
+
{ selector: 'node.copy.copy-sel', style: { 'background-opacity': 1, 'background-image-opacity': [COPY_OPACITY.selected, 1], 'border-opacity': 1 } },
|
|
733
|
+
{ selector: 'node.copy.copy-hover', style: { 'background-opacity': 1, 'background-image-opacity': [COPY_OPACITY.hover, 1], 'border-opacity': 1 } },
|
|
734
|
+
{ selector: 'node.copy.hit', style: { 'background-opacity': 1, 'background-image-opacity': [1, 1], 'border-opacity': 1 } },
|
|
735
|
+
// Compare: what appeared, what changed, what is gone. Colour carries it; nothing changes size (D45, D46).
|
|
736
|
+
{ selector: 'node.diff-added', style: { 'border-color': p.diffAdded, 'border-width': 3 } },
|
|
737
|
+
{ selector: 'node.diff-changed', style: { 'border-color': p.diffChanged, 'border-width': 3 } },
|
|
738
|
+
{ selector: 'node.diff-removed', style: { 'border-color': p.diffRemoved, 'border-width': 3, 'opacity': 0.5 } },
|
|
739
|
+
{ selector: 'node.focus', style: { 'opacity': 1, 'border-color': p.selection, 'border-width': 3, 'outline-color': p.focus, 'outline-width': 7, 'outline-opacity': 0.75, 'outline-offset': 2, 'z-index': 30 } },
|
|
740
|
+
// Lines: the style says the basis only (solid Explicit, dotted Inferred); the type is written on a line when it
|
|
741
|
+
// is picked out, never told by colour (D53).
|
|
742
|
+
{ selector: 'edge', style: {
|
|
743
|
+
'width': 1.1, 'line-color': p.edge, 'target-arrow-color': p.edge, 'target-arrow-shape': 'triangle', 'arrow-scale': 0.7, 'curve-style': 'bezier', 'opacity': 0.85,
|
|
744
|
+
'label': 'data(mark)', 'font-size': TEXT.kind, 'font-family': FONT, 'color': p.nodeText, 'text-opacity': 1, 'text-background-color': p.labelBg, 'text-background-opacity': 1, 'text-background-padding': 2, 'text-background-shape': 'roundrectangle',
|
|
745
|
+
'text-border-color': p.chipBorder, 'text-border-width': 1, 'text-border-opacity': 1, 'text-rotation': 'none', 'min-zoomed-font-size': 5,
|
|
746
|
+
} },
|
|
747
|
+
{ selector: 'edge[mark = ""]', style: { 'text-background-opacity': 0, 'text-border-opacity': 0 } },
|
|
748
|
+
// A relation with a note that still needs the owner carries a lit ❓ on its line (D100); the others a quiet one.
|
|
749
|
+
{ selector: 'edge.noted', style: { 'text-opacity': 0.6 } },
|
|
750
|
+
{ selector: 'edge.own, edge.implied', style: { 'display': 'none' } },
|
|
751
|
+
{ selector: 'edge.other', style: { 'display': 'none', 'line-color': p.edgeOther, 'target-arrow-color': p.edgeOther } },
|
|
752
|
+
{ selector: 'edge.own.inferred, edge.other.all-links', style: { 'display': 'element' } },
|
|
753
|
+
{ selector: 'edge.inferred', style: { 'line-style': 'dotted', 'line-color': p.edgeOther, 'target-arrow-color': p.edgeOther } },
|
|
754
|
+
{ selector: 'edge.questioned', style: { 'display': 'element', 'width': 2, 'line-color': p.questioned, 'target-arrow-color': p.questioned, 'opacity': 1, 'label': 'data(elabel)', 'color': p.questioned, 'text-border-color': p.questioned, 'text-background-opacity': 1, 'text-border-opacity': 1 } },
|
|
755
|
+
{ selector: 'edge.ask', style: { 'display': 'element', 'text-opacity': 1, 'text-border-color': p.markAsk, 'text-border-width': 2, 'text-background-color': tint(p.markAsk, 0.75, p.chipBg) } },
|
|
756
|
+
{ selector: 'edge.related', style: { 'display': 'element', 'opacity': 1, 'line-color': p.edgeRelated, 'target-arrow-color': p.edgeRelated, 'z-index': 9 } },
|
|
757
|
+
{ selector: 'edge.lit', style: { 'display': 'element', 'opacity': 1, 'width': 1.6, 'line-color': p.edgeLit, 'target-arrow-color': p.edgeLit, 'label': 'data(elabel)', 'text-background-opacity': 1, 'text-border-opacity': 1, 'z-index': 20 } },
|
|
758
|
+
{ selector: 'edge.lit.questioned', style: { 'line-color': p.questioned, 'target-arrow-color': p.questioned } },
|
|
759
|
+
{ selector: 'edge.faded', style: { 'opacity': 0.08 } },
|
|
760
|
+
{ selector: 'edge:selected', style: { 'display': 'element', 'line-color': p.edgeSelected, 'target-arrow-color': p.edgeSelected, 'width': 2.5, 'label': 'data(elabel)', 'text-background-opacity': 1, 'text-border-opacity': 1, 'opacity': 1 } },
|
|
761
|
+
// A relation's mark: the line itself, thick and in the focus colour, including a line the picture otherwise hides.
|
|
762
|
+
{ selector: 'edge.hit', style: {
|
|
763
|
+
'display': 'element', 'width': 6, 'opacity': 1, 'z-index': 45,
|
|
764
|
+
'line-color': p.focus, 'target-arrow-color': p.focus,
|
|
765
|
+
'underlay-color': p.focus, 'underlay-padding': 8, 'underlay-opacity': 0.9,
|
|
766
|
+
} },
|
|
767
|
+
// ── The process view (CKC-24; mockup graph-process-v0): steps are smaller hexagons; a send-back still open is an
|
|
768
|
+
// ellipse; the arrow of a send-back going back is dashed red, a hand-off curved blue, a pointer to another area's
|
|
769
|
+
// work dotted. The labels say the stage, so the colour is never the only carrier. ──
|
|
770
|
+
{ selector: 'node.proc-step', style: { 'shape': 'polygon', 'shape-polygon-points': HEX_POLYGON, 'border-color': p.work } },
|
|
771
|
+
// Solid, like every object but a work item's copy (Spec §6.3: a dashed frame is only that); the ellipse and the red say it.
|
|
772
|
+
{ selector: 'node.proc-suggest', style: { 'shape': 'ellipse', 'border-color': p.danger, 'border-width': 2 } },
|
|
773
|
+
{ selector: 'node.proc-suggest.returned', style: { 'border-color': p.questioned } },
|
|
774
|
+
{ selector: 'node.proc-alert', style: { 'border-color': p.danger, 'border-width': 2.5 } },
|
|
775
|
+
{ selector: 'edge.proc-ret', style: { 'line-style': 'dashed', 'line-color': p.danger, 'target-arrow-color': p.danger, 'color': p.danger, 'label': 'data(elabel)', 'z-index': 6 } },
|
|
776
|
+
{ selector: 'edge.proc-sug', style: { 'line-style': 'dashed', 'line-color': p.danger, 'target-arrow-color': p.danger, 'color': p.danger, 'label': 'data(elabel)', 'z-index': 6 } },
|
|
777
|
+
{ selector: 'edge.proc-sug.plan', style: { 'line-color': p.questioned, 'target-arrow-color': p.questioned, 'color': p.questioned } },
|
|
778
|
+
{ selector: 'edge.proc-hand', style: { 'line-style': 'dashed', 'line-color': p.intent, 'target-arrow-color': p.intent, 'color': p.intent, 'label': 'data(elabel)', 'curve-style': 'unbundled-bezier', 'control-point-distances': [-140], 'control-point-weights': [0.5], 'z-index': 6 } },
|
|
779
|
+
{ selector: 'edge.proc-ptr', style: { 'line-style': 'dotted', 'line-color': p.edgeOther, 'target-arrow-color': p.edgeOther, 'label': 'data(elabel)', 'curve-style': 'unbundled-bezier', 'control-point-distances': [120], 'control-point-weights': [0.5] } },
|
|
780
|
+
{ selector: 'edge.proc-chain', style: { 'label': 'data(elabel)' } },
|
|
781
|
+
];
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
export function createGraph(container, handlers) {
|
|
785
|
+
ensureRegistered();
|
|
786
|
+
refreshPalette();
|
|
787
|
+
const cy = cytoscape({
|
|
788
|
+
container,
|
|
789
|
+
wheelSensitivity: 1,
|
|
790
|
+
// How far the owner's own wheel and pinch go; lowered when the whole picture needs less (setZoomLimit), so the whole
|
|
791
|
+
// of a large project can always be reached by hand. It is not the floor: that one (ZOOM_FLOOR) is for the views the
|
|
792
|
+
// graph chooses, and the graph holds itself to it (fitReadable, showAffected).
|
|
793
|
+
minZoom: ZOOM_HARD_MIN,
|
|
794
|
+
style: graphStyle(palette),
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
const view = {
|
|
798
|
+
data: null, scale: 'Overview', showReplaced: false, links: 'counts', expandedGroups: new Set(), expandedWork: new Set(),
|
|
799
|
+
filters: { category: '', validity: '', progress: '', acceptance: '', assessment: '', withNotes: false, recent: false, bpKind: '', sbStage: '', sixThing: '' }, focusId: null, selectionId: null, compare: null,
|
|
800
|
+
starMode: 'visit', lastVisit: null, starred: new Set(), starNote: '',
|
|
801
|
+
};
|
|
802
|
+
let lastKey = '';
|
|
803
|
+
let firstLayout = true;
|
|
804
|
+
let lastEmph = '';
|
|
805
|
+
const emphasisKey = () => `${view.scale}|${view.selectionId ?? ''}|${view.focusId ?? ''}|${view.compare?.side ?? ''}|${view.compare?.index ?? ''}|${(view.compare?.items ?? []).map((i) => `${i.id}:${i.kind}`).join(',')}`;
|
|
806
|
+
let hoverId = null;
|
|
807
|
+
// The copy a work item was selected from: its popover stands beside that copy (rectOf) until something else is picked.
|
|
808
|
+
let anchorAt = null;
|
|
809
|
+
let layoutInfo = null;
|
|
810
|
+
// Marks left by `Show on graph` and `Show affected`. They stay until the owner picks something else, clicks the
|
|
811
|
+
// blank graph, or closes the popover (`clearMarks`) — not a flash.
|
|
812
|
+
let pinned = [];
|
|
813
|
+
|
|
814
|
+
// ── The view (CKC-09 AC-39; Spec §6.3 "布局") ─────────────────────────────
|
|
815
|
+
// `auto`: the view is the one the graph chose — the whole picture, or its top left at the zoom floor. It stays the
|
|
816
|
+
// graph's through every change of size (the window, or a panel docking beside the graph) and is worked out again each
|
|
817
|
+
// time, until the owner zooms or drags themselves, or asks for the whole picture (`Show all anyway`). From then on a
|
|
818
|
+
// change of size leaves their view alone, whatever its zoom — the owner may be below the floor, to see the shape of a
|
|
819
|
+
// large project — and only keeps the picture from being left out of sight. `Fit` gives the view back to the graph.
|
|
820
|
+
let auto = true;
|
|
821
|
+
let chosen = null; // the view the graph last chose, to tell the owner's move from a wheel turn that changed nothing
|
|
822
|
+
let seen = { w: 0, h: 0 }; // the window's size the view was last settled for
|
|
823
|
+
let readTimer = 0;
|
|
824
|
+
// Read off the container itself: cytoscape keeps its own copy of the size until it looks again, which may be later.
|
|
825
|
+
const windowSize = () => ({ w: container.clientWidth, h: container.clientHeight });
|
|
826
|
+
/** The objects as they stand, by centre and size, with what stands out of them; the viewport and the self-check both read this. */
|
|
827
|
+
// The bands and the ring are backgrounds: what is fitted and what the self-check judges are the objects on them.
|
|
828
|
+
const pictureNodes = () => cy.nodes().filter((n) => !n.hasClass('band') && !n.hasClass('ring')).map((n) => { const p = n.position(); return { id: n.id(), label: n.data('rawLabel'), areaId: n.data('areaId') ?? null, x: p.x, y: p.y, w: n.data('w') ?? n.width(), h: n.data('h') ?? n.height(), top: n.data('overTop') ?? 0, right: n.data('overRight') ?? 0 }; });
|
|
829
|
+
const boxOf = (el) => { const p = el.position(), w = el.data('w') ?? el.width(), h = el.data('h') ?? el.height(); return { x1: p.x - w / 2, y1: p.y - h / 2 - (el.data('overTop') ?? 0), x2: p.x + w / 2 + (el.data('overRight') ?? 0), y2: p.y + h / 2 }; };
|
|
830
|
+
/** How far the owner's own wheel and pinch go, for the picture and the window as they are now (graph-fit.js
|
|
831
|
+
* hardMinZoom). Never above the zoom the owner is at: when the picture gets smaller while they are zoomed far out,
|
|
832
|
+
* the limit waits for them to zoom in, so that setting it moves nothing under them. */
|
|
833
|
+
/** Room the page keeps clear at the top of the graph's window (its buttons float over the top right corner, CKC-09
|
|
834
|
+
* AC-37): every view worked out here, and the self-check that judges them, take it off the same way (graph-fit.js). */
|
|
835
|
+
const fitOpts = () => { const top = handlers.fitPadTop?.() ?? 0; return top > 0 ? { padTop: top } : {}; };
|
|
836
|
+
const setZoomLimit = () => { cy.minZoom(Math.min(hardMinZoom(pictureBox(pictureNodes()), windowSize(), fitOpts()), cy.zoom())); };
|
|
837
|
+
// cytoscape says `scrollzoom` for every turn of the wheel, also for one the hard limit swallowed, so the view is compared.
|
|
838
|
+
cy.on('scrollzoom pinchzoom dragpan', () => {
|
|
839
|
+
const pan = cy.pan();
|
|
840
|
+
if (!chosen || Math.abs(cy.zoom() - chosen.zoom) > 1e-6 || Math.abs(pan.x - chosen.pan.x) > 0.5 || Math.abs(pan.y - chosen.pan.y) > 0.5) auto = false;
|
|
841
|
+
});
|
|
842
|
+
/** The owner's view, or one made for them (an object brought into view, what a change touched): no longer the graph's. */
|
|
843
|
+
const ownView = () => { auto = false; chosen = null; };
|
|
844
|
+
/**
|
|
845
|
+
* After the graph's window changed size. Its container is watched, not only the browser window: a panel docking
|
|
846
|
+
* beside the graph changes the container and nothing else. A selected object that was in sight stays in sight.
|
|
847
|
+
*/
|
|
848
|
+
function onResize() {
|
|
849
|
+
const now = windowSize();
|
|
850
|
+
if (Math.abs(now.w - seen.w) < 2 && Math.abs(now.h - seen.h) < 2) return;
|
|
851
|
+
const before = seen, zoom = cy.zoom(), pan = { ...cy.pan() };
|
|
852
|
+
seen = now;
|
|
853
|
+
cy.resize(); // cytoscape reads its container again; the `resize` it then says comes back here and finds nothing new
|
|
854
|
+
if (now.w < 2 || now.h < 2 || !cy.nodes().length) return;
|
|
855
|
+
setZoomLimit();
|
|
856
|
+
if (auto) fitReadable();
|
|
857
|
+
else { const kept = keepInView(pictureBox(pictureNodes()), now, zoom, pan); if (kept !== pan) cy.pan(kept); }
|
|
858
|
+
const sel = view.selectionId ? cy.getElementById(view.selectionId) : null;
|
|
859
|
+
if (sel?.length && sel.isNode() && before.w >= 2 && inView(boxOf(sel), before, zoom, pan) && !inView(boxOf(sel), windowSize(), cy.zoom(), cy.pan())) {
|
|
860
|
+
cy.pan(panToShow(boxOf(sel), windowSize(), cy.zoom(), cy.pan()));
|
|
861
|
+
if (auto) chosen = { zoom: cy.zoom(), pan: { ...cy.pan() } };
|
|
862
|
+
}
|
|
863
|
+
// Whether the picture is too big to read at once depends on the window; said again once the size has settled.
|
|
864
|
+
clearTimeout(readTimer);
|
|
865
|
+
readTimer = setTimeout(() => { if (!cy.destroyed()) checkReadability(); }, 150);
|
|
866
|
+
}
|
|
867
|
+
const resizeWatch = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(onResize);
|
|
868
|
+
resizeWatch?.observe(container);
|
|
869
|
+
cy.on('resize', onResize);
|
|
870
|
+
|
|
871
|
+
// The full name and what the marks mean are in the tooltip (Spec §6.3: names are two lines at most on the object).
|
|
872
|
+
const tip = document.createElement('div');
|
|
873
|
+
tip.className = 'graph-tip';
|
|
874
|
+
tip.hidden = true;
|
|
875
|
+
document.body.append(tip);
|
|
876
|
+
const showTip = (e, html) => { tip.innerHTML = html; tip.hidden = false; moveTip(e); };
|
|
877
|
+
const moveTip = (e) => { const o = e.originalEvent; if (!o) return; const W = tip.offsetWidth, H = tip.offsetHeight; tip.style.left = `${Math.min(o.clientX + 14, innerWidth - W - 8)}px`; tip.style.top = `${Math.min(o.clientY + 14, innerHeight - H - 8)}px`; };
|
|
878
|
+
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<');
|
|
879
|
+
function tipFor(el) {
|
|
880
|
+
const d = el.data();
|
|
881
|
+
if (d.kind === 'folder') {
|
|
882
|
+
const where = d.folder ? cleanName(view.data?.nodes.find((n) => n.id === d.folder)?.label ?? '') : 'Cross-cutting';
|
|
883
|
+
return `<div class="muted">Observed reality · ${esc(where)}</div><div>${d.total ? `${d.total} — ${esc(d.summary)}` : 'No result or verification recorded yet'}</div><div class="faint">Click to open the same place in List</div>`;
|
|
884
|
+
}
|
|
885
|
+
if (d.kind === 'group' || d.kind === 'genhead') {
|
|
886
|
+
const noted = d.marks ? [d.marks.attention ? `<div class="muted">❓ ${d.marks.attention} ${d.marks.attention === 1 ? 'note needs' : 'notes need'} you inside</div>` : '', d.marks.quiet ? `<div class="faint">❓ ${d.marks.quiet} other ${d.marks.quiet === 1 ? 'note' : 'notes'} inside</div>` : ''].join('') : '';
|
|
887
|
+
return `<div>${esc(d.tip ?? d.rawLabel)}${d.count != null ? ` (${d.count})` : ''}</div>${noted}${d.why ? `<div class="faint">${esc(d.why)}</div>` : ''}${d.fold ? `<div class="faint">${d.open ? 'Click to fold it again' : 'Click to open it'}</div>` : ''}`;
|
|
888
|
+
}
|
|
889
|
+
if (d.kind === 'colhead' || d.kind === 'bandhead') return `<div>${esc(d.tip ?? d.rawLabel)}</div>`;
|
|
890
|
+
const n = view.data?.nodes.find((x) => x.id === (d.copyOf ?? el.id()));
|
|
891
|
+
if (!n) return '';
|
|
892
|
+
const bits = [`<div class="muted">${esc(n.category)}${n.progress && isWork(n.category) ? ` · ${esc(n.progress)}` : ''}${n.validity && n.validity !== 'Current' ? ` · ${esc(n.validity)}` : ''}${n.basis === 'Inferred' ? ' · Inferred' : ''}</div>`, `<div>${esc(cleanName(n.label))}</div>`];
|
|
893
|
+
for (const m of marksOf(n, view.starred.has(n.id))) bits.push(`<div class="muted"><span style="color:${m.color}">${m.glyph}</span> ${esc(m === MARKS.flag ? n.marks.map((x) => x.kind).join(', ') : m.legend)}</div>`);
|
|
894
|
+
if (n.category === 'Area' && n.foundation) bits.push(`<div class="muted">${FOUNDATION_LABEL}: the base the modules share, not a module users face</div>`);
|
|
895
|
+
// The second line in full (it is cut to one line on the object), what is open, and that a click opens its steps.
|
|
896
|
+
if (d.line2 && !d.copyOf) bits.push(`<div class="muted">${esc(d.line2)}</div>`);
|
|
897
|
+
if (d.openText) bits.push(`<div class="muted"><span style="color:${palette.danger}">${OPEN_SIGN}</span> ${esc(d.openText)}</div>`);
|
|
898
|
+
if (d.steps) bits.push(`<div class="faint">${d.steps === 'open' ? 'Click to fold its steps' : 'Click to open its steps'}</div>`);
|
|
899
|
+
if (d.where) bits.push(`<div class="faint">${esc(d.where)}</div>`);
|
|
900
|
+
const links = d.links ?? 0;
|
|
901
|
+
if (links && view.links === 'counts') bits.push(`<div class="faint">↗${links}: ${plural(links, 'more relation')} out of its place — drawn while you point at it or select it</div>`);
|
|
902
|
+
return bits.join('');
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
cy.on('tap', 'node', (e) => {
|
|
906
|
+
const n = e.target;
|
|
907
|
+
tip.hidden = true; // what opens beside the object says more than the tip, and the tip would lie on it
|
|
908
|
+
if (n.data('kind') === 'folder') { handlers.onFolder?.(n.data('folder') ?? null); return; }
|
|
909
|
+
// A folded group, a stacked cell, the ring's groups, a rolled generation: opened from their card, and folded again
|
|
910
|
+
// from the same card, open or not (owner, 2026-09-30: what opens has to fold again where it opened).
|
|
911
|
+
if (n.data('fold')) { toggleGroup(n.data('fold')); return; }
|
|
912
|
+
if (n.data('kind') === 'colhead' || n.data('kind') === 'bandhead') return;
|
|
913
|
+
// A send-back ellipse opens the send-back itself; a step stands for its work's details (CKC-24).
|
|
914
|
+
if (n.data('kind') === 'proc-suggest') { handlers.onProcNode?.(n.data('sbId'), n.id()); return; }
|
|
915
|
+
if (n.data('kind') === 'proc-step') { const w = n.data('procOf'); const node = view.data?.nodes.find((x) => x.id === w); handlers.onSelect?.({ kind: 'node', id: w, label: node?.label ?? w, category: node?.category }); return; }
|
|
916
|
+
// A dashed copy selects the work item it stands for (owner, 2026-09-30): the same popover and floating window,
|
|
917
|
+
// standing beside the copy that was clicked.
|
|
918
|
+
if (n.data('copyOf')) { anchorAt = { id: n.data('copyOf'), el: n.id() }; handlers.onSelect?.({ kind: 'node', id: n.data('copyOf'), label: n.data('rawLabel'), category: n.data('category') }); return; }
|
|
919
|
+
anchorAt = null;
|
|
920
|
+
// A tap on a work with a process grows or folds its branch (mockup graph-process-v0); the details open as usual.
|
|
921
|
+
if (procLayer?.expandable?.(n.id())) { procLayer.toggle(n.id()); render(); }
|
|
922
|
+
handlers.onSelect?.({ kind: 'node', id: n.id(), label: n.data('rawLabel'), category: n.data('category') });
|
|
923
|
+
});
|
|
924
|
+
cy.on('tap', 'edge', (e) => {
|
|
925
|
+
const r = e.target;
|
|
926
|
+
if (r.data('part') === 'proc') { if (r.data('sbId')) handlers.onProcNode?.(r.data('sbId'), r.id()); return; }
|
|
927
|
+
if (r.data('synthetic')) return; // the ring's lines to the columns stand for relations inside the fold
|
|
928
|
+
handlers.onSelect?.({ kind: 'relation', id: r.id(), label: `${r.data('type')}: ${r.data('fromLabel')} → ${r.data('toLabel')}`, at: e.position ? { x: e.position.x, y: e.position.y } : null });
|
|
929
|
+
});
|
|
930
|
+
cy.on('tap', (e) => { if (e.target === cy) handlers.onSelect?.(null); });
|
|
931
|
+
cy.on('dbltap', 'node', (e) => { const n = e.target; if (n.data('fold') || n.data('kind')) return; toggleExpand(n.data('copyOf') ?? n.id()); });
|
|
932
|
+
// Pointing at a copy draws the work item's relations from its own element.
|
|
933
|
+
cy.on('mouseover', 'node', (e) => { hoverId = e.target.data('copyOf') ?? e.target.id(); if (e.target.hasClass('copy')) e.target.addClass('copy-hover'); showTip(e, tipFor(e.target)); lightLinks(); });
|
|
934
|
+
cy.on('mousemove', 'node', (e) => moveTip(e));
|
|
935
|
+
cy.on('mouseout', 'node', (e) => { hoverId = null; tip.hidden = true; e.target.removeClass('copy-hover'); lightLinks(); });
|
|
936
|
+
cy.on('viewport', () => { tip.hidden = true; });
|
|
937
|
+
// Whatever stands beside an object on the page (its details popover) follows it: a pan, a zoom, a new layout.
|
|
938
|
+
const moved = () => handlers.onViewport?.();
|
|
939
|
+
cy.on('viewport resize', moved);
|
|
940
|
+
cy.on('position', 'node', moved);
|
|
941
|
+
// The canvas follows its container as the Keeper conversation is docked, resized and put away (Spec §6.8): `onResize`
|
|
942
|
+
// above watches the container and tells cytoscape, whose `resize` then comes here too.
|
|
943
|
+
|
|
944
|
+
function toggleGroup(name) { if (view.expandedGroups.has(name)) view.expandedGroups.delete(name); else view.expandedGroups.add(name); render(); }
|
|
945
|
+
function toggleExpand(id) {
|
|
946
|
+
const node = view.data?.nodes.find((n) => n.id === id);
|
|
947
|
+
if (!node) return;
|
|
948
|
+
// An area opens and folds its requirements, designs and decisions (the card at its column's top does the same).
|
|
949
|
+
if (node.category === 'Area') { const k = `intent:${id}`; if (view.expandedGroups.has(k)) view.expandedGroups.delete(k); else view.expandedGroups.add(k); }
|
|
950
|
+
else { if (view.expandedWork.has(id)) view.expandedWork.delete(id); else view.expandedWork.add(id); }
|
|
951
|
+
render();
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
function upstreamOf(id, rel, acc, depth = 0) {
|
|
956
|
+
if (depth > 8 || acc.has(id)) return acc;
|
|
957
|
+
acc.add(id);
|
|
958
|
+
for (const r of rel.filter((x) => x.from === id && (x.type === 'serves' || x.type === 'refines' || x.type === 'implements'))) upstreamOf(r.to, rel, acc, depth + 1);
|
|
959
|
+
return acc;
|
|
960
|
+
}
|
|
961
|
+
function downstreamOf(id, rel, acc, depth = 0) {
|
|
962
|
+
if (depth > 6 || acc.has(id)) return acc;
|
|
963
|
+
acc.add(id);
|
|
964
|
+
for (const r of rel.filter((x) => x.to === id && x.type !== 'affects')) downstreamOf(r.from, rel, acc, depth + 1);
|
|
965
|
+
for (const r of rel.filter((x) => x.from === id && (x.type === 'depends on' || x.type === 'produced' || x.type === 'affects'))) downstreamOf(r.to, rel, acc, depth + 1);
|
|
966
|
+
return acc;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* What the story map draws, and where (D100; Spec §6.3 "默认可见"): every object in its place from the placement model
|
|
971
|
+
* (ui/placement.js), or folded into the card of its place — an area's requirements, designs and decisions at the
|
|
972
|
+
* column's top, the ring's groups, a plan's execution decisions, a cell's `Existing foundation` and the rest of a full
|
|
973
|
+
* cell, the cells of what is not placed yet, a rolled earlier generation. What is selected, in focus or matched by a
|
|
974
|
+
* filter is drawn in its place even when its fold is closed. Returns the entries in drawing order (objects and the
|
|
975
|
+
* cards of the folds), which card holds each folded object, and the drawn objects.
|
|
976
|
+
*/
|
|
977
|
+
function visibleSet(M) {
|
|
978
|
+
const d = view.data;
|
|
979
|
+
const byId = new Map(d.nodes.map((n) => [n.id, n]));
|
|
980
|
+
const f = view.filters;
|
|
981
|
+
const filterActive = f.category || f.validity || f.progress || f.acceptance || f.assessment || f.withNotes || f.recent || f.attention || f.bpKind || f.sbStage || f.sixThing;
|
|
982
|
+
const matches = (n) => {
|
|
983
|
+
if (f.category && n.category !== f.category) return false;
|
|
984
|
+
if (f.validity && n.validity !== f.validity) return false;
|
|
985
|
+
if (f.progress && n.progress !== f.progress) return false;
|
|
986
|
+
if (f.acceptance && n.acceptance !== f.acceptance) return false;
|
|
987
|
+
if (f.withNotes && !n.noteCount) return false;
|
|
988
|
+
if (f.recent && !view.starred.has(n.id)) return false;
|
|
989
|
+
if (f.attention && !(f.attentionIds ?? []).includes(n.id)) return false; // CF: the drawer's ❓ count (views.js setAttentionFilter)
|
|
990
|
+
if (f.assessment) { const has = d.relations.some((r) => (r.from === n.id || r.to === n.id) && r.assessment === f.assessment); if (!has) return false; }
|
|
991
|
+
// The process filters (CKC-24 AC-16): a breakpoint kind, a send-back's stage, one of the six things.
|
|
992
|
+
if ((f.bpKind || f.sbStage || f.sixThing) && !(procLayer?.match?.(n, f) ?? true)) return false;
|
|
993
|
+
return true;
|
|
994
|
+
};
|
|
995
|
+
const forced = (n) => n.id === view.selectionId || n.id === view.focusId || Boolean(filterActive && matches(n));
|
|
996
|
+
const drawable = (n) => Boolean(n) && n.validity !== 'Unjudged' && !NOT_DRAWN.has(n.category);
|
|
997
|
+
const shown = new Set(), entries = [], cardOf = new Map();
|
|
998
|
+
const A = M.areas.length;
|
|
999
|
+
// A work item serving several areas is drawn in each (owner, 2026-09-30): its own element in its own cell, and in
|
|
1000
|
+
// every other area's cell a dashed copy with an element of its own that stands for it (`copy:<area>:<id>`). A copy
|
|
1001
|
+
// is filtered, compared, folded and marked as the work item it stands for.
|
|
1002
|
+
const copyOf = new Map();
|
|
1003
|
+
const real = (id) => copyOf.get(id) ?? id;
|
|
1004
|
+
const copyIn = (area, id) => { const c = `copy:${area}:${id}`; copyOf.set(c, id); return c; };
|
|
1005
|
+
const node = (id, slot) => { if (shown.has(id) || !drawable(byId.get(real(id)))) return; shown.add(id); entries.push(copyOf.has(id) ? { id, slot, copyOf: copyOf.get(id) } : { id, slot }); };
|
|
1006
|
+
const notesIn = (ids) => {
|
|
1007
|
+
const m = { attention: 0, quiet: 0, flagged: 0 };
|
|
1008
|
+
for (const id of ids) { const n = byId.get(real(id)); if (!n) continue; m.attention += n.noteAttention ?? 0; m.quiet += Math.max(0, (n.noteCount ?? 0) - (n.noteAttention ?? 0)); if (n.marks?.length) m.flagged++; }
|
|
1009
|
+
return m;
|
|
1010
|
+
};
|
|
1011
|
+
/** A fold: its card (with the count of what it holds and the ❓ inside, lit while folded) and, open, its objects. */
|
|
1012
|
+
const fold = (key, ids, slot, label, extra = {}) => {
|
|
1013
|
+
const list = ids.filter((id) => drawable(byId.get(real(id))));
|
|
1014
|
+
if (!list.length) return;
|
|
1015
|
+
const open = view.expandedGroups.has(key);
|
|
1016
|
+
const inside = open ? [] : list.filter((id) => !forced(byId.get(real(id))));
|
|
1017
|
+
const cardId = extra.id ?? `card:${key}`;
|
|
1018
|
+
if (open || inside.length) entries.push({ card: true, id: cardId, key, label, count: open ? list.length : inside.length, open, slot, marks: notesIn(inside), inside, ids: list, ...extra });
|
|
1019
|
+
for (const id of inside) cardOf.set(id, cardId);
|
|
1020
|
+
for (const id of list) if (!inside.includes(id)) node(id, slot);
|
|
1021
|
+
};
|
|
1022
|
+
const kinds = (ids) => { const c = {}; for (const id of ids) { const k = byId.get(id)?.category; if (k) c[k] = (c[k] ?? 0) + 1; } return c; };
|
|
1023
|
+
const LONG = { Requirement: ['requirement', 'requirements'], Design: ['design', 'designs'], Decision: ['decision', 'decisions'] };
|
|
1024
|
+
const SHORT = { Requirement: 'req', Design: 'design', Decision: 'dec' };
|
|
1025
|
+
// On a card the counts by kind in short (the card is one line); in its tooltip in full.
|
|
1026
|
+
const kindsLabel = (ids) => { const c = kinds(ids); return INTENT_KINDS.filter((k) => c[k]).map((k) => `${c[k]} ${LONG[k][c[k] === 1 ? 0 : 1]}`).join(' · ') || 'nothing'; };
|
|
1027
|
+
const kindsShort = (ids) => { const c = kinds(ids); return INTENT_KINDS.filter((k) => c[k]).map((k) => `${SHORT[k]} ${c[k]}`).join(' · ') || '—'; };
|
|
1028
|
+
|
|
1029
|
+
// The owner's words on top, folded with their count (D63).
|
|
1030
|
+
const words = d.nodes.filter((n) => isWordsItem(n) && drawable(n) && M.place.get(n.id)?.zone === 'words').sort(byName).map((n) => n.id);
|
|
1031
|
+
fold(OWNER_WORDS, words, { zone: 'words' }, OWNER_WORDS, { id: WORDS_GROUP_ID, group: OWNER_WORDS });
|
|
1032
|
+
for (const n of d.nodes) { const z = M.place.get(n.id)?.zone; if (z === 'product' || z === 'goal') node(n.id, { zone: z }); }
|
|
1033
|
+
M.areas.forEach((a, col) => node(a.id, { zone: 'area', col }));
|
|
1034
|
+
entries.push({ special: 'colhead', id: 'colhead:cross', slot: { zone: 'colhead', col: A } });
|
|
1035
|
+
// Each column's requirements, designs and decisions, folded at its top.
|
|
1036
|
+
M.areas.forEach((a, col) => { const ids = M.intent.get(a.id) ?? []; fold(`intent:${a.id}`, ids, { zone: 'intent', col }, kindsShort(ids), { tip: `${cleanName(a.label)}: its requirements, designs and decisions — ${kindsLabel(ids)}` }); });
|
|
1037
|
+
// The cross-cutting ring: several areas; the whole product, with the Keeper's written reason (CM); only on the
|
|
1038
|
+
// product; on nothing yet (the last two are not placed, §1.4).
|
|
1039
|
+
const ringSlot = { zone: 'ring' };
|
|
1040
|
+
fold('ring:multi', M.ring.multi, ringSlot, `several areas · ${kindsShort(M.ring.multi)}`, { tip: `Reaching several areas — ${kindsLabel(M.ring.multi)}; each is drawn to the columns it reaches`, why: 'Placed: each reaches more than one area, drawn to every column it reaches' });
|
|
1041
|
+
fold('ring:whole', M.ring.whole, ringSlot, `whole product · ${kindsShort(M.ring.whole)}`, { tip: `${WHOLE_LABEL} — ${kindsLabel(M.ring.whole)}; each concerns the whole product rather than one area or plan`, why: 'Placed: the Keeper wrote why each concerns the whole product; the reason is on each item' });
|
|
1042
|
+
fold('ring:product', M.ring.product, ringSlot, `product only · ${kindsShort(M.ring.product)}`, { tip: `Only on the product — ${kindsLabel(M.ring.product)}; not yet placed on an area or a plan`, why: 'Not placed yet: on the product alone, which says nothing about where it acts', unplaced: true });
|
|
1043
|
+
fold('ring:none', M.ring.none, ringSlot, `not placed yet · ${kindsShort(M.ring.none)}`, { tip: `On nothing yet — ${kindsLabel(M.ring.none)}; not placed on an area, a plan or the product`, why: 'Not placed yet: the Keeper has not tied it to anything it acts on', unplaced: true });
|
|
1044
|
+
|
|
1045
|
+
// The bands, top to bottom.
|
|
1046
|
+
const cellsOf = (bandKey, bi, kind) => {
|
|
1047
|
+
for (let col = 0; col <= A; col++) {
|
|
1048
|
+
const area = col < A ? M.areas[col].id : null;
|
|
1049
|
+
const key = cellKey(bandKey, area);
|
|
1050
|
+
const ids = (M.cells.get(key) ?? []).filter((id) => drawable(byId.get(id))).map((id) => (isCopyIn(M, id, area) ? copyIn(area, id) : id));
|
|
1051
|
+
if (!ids.length) continue;
|
|
1052
|
+
ids.sort((a, b) => { const x = byId.get(real(a)), y = byId.get(real(b)); return (PROGRESS_ORDER[x.progress] ?? 4) - (PROGRESS_ORDER[y.progress] ?? 4) || byName(x, y); });
|
|
1053
|
+
const slot = { zone: 'cell', band: bandKey, bi, col, kind };
|
|
1054
|
+
// What is not placed yet is counted in the place it would take (Spec §1.4, §6.3): one card per cell. In a plan's
|
|
1055
|
+
// cross-cutting cell, work for the whole plan is placed (CN, E152): drawn as in any cell, the card after it.
|
|
1056
|
+
const loose = kind === 'none' || !area ? ids.filter((id) => area || !M.place.get(id)?.whole) : [];
|
|
1057
|
+
const placed = ids.filter((id) => !loose.includes(id));
|
|
1058
|
+
const foundation = placed.filter((id) => byId.get(real(id)).group === 'Existing foundation');
|
|
1059
|
+
const rest = placed.filter((id) => !foundation.includes(id));
|
|
1060
|
+
for (const id of rest.slice(0, CELL_SHOWN)) node(id, slot);
|
|
1061
|
+
if (rest.length > CELL_SHOWN) fold(`more:${key}`, rest.slice(CELL_SHOWN), slot, 'more work here', { tip: 'More work in this cell' });
|
|
1062
|
+
if (area) fold(`found:${key}`, foundation, slot, 'Existing foundation', { tip: 'Existing foundation: done, still in force, not in the current focus' });
|
|
1063
|
+
else fold(`found:${key}`, foundation, slot, `${WHOLE_PLAN_LABEL.toLowerCase()} · done`, { tip: 'Done work for the whole plan — all of its modules, no single one; each says why on its second line', why: 'Placed: the Keeper wrote why each serves its whole plan; the reason is on each item' });
|
|
1064
|
+
if (loose.length) {
|
|
1065
|
+
const what = kind === 'none' ? (area ? 'not in a plan yet' : 'no plan, no area yet') : kind === 'gen' ? 'in no current area' : 'no area yet';
|
|
1066
|
+
fold(`cell:${key}`, loose, slot, what, { tip: `${loose.length} work item${loose.length === 1 ? '' : 's'} ${what}`, why: kind === 'gen' ? 'Its area is not one of the current areas' : 'Not placed yet: no record read so far says where it belongs', unplaced: kind !== 'gen' });
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
M.bands.forEach((b, bi) => {
|
|
1071
|
+
if (b.kind === 'gen') {
|
|
1072
|
+
const g = b.gen;
|
|
1073
|
+
const key = `gen:${g.id}`;
|
|
1074
|
+
const open = view.expandedGroups.has(key);
|
|
1075
|
+
const head = { zone: 'head', band: g.id, bi, kind: 'gen' };
|
|
1076
|
+
// Each item once, however many areas it stands in (its copies open with the band).
|
|
1077
|
+
const all = [...new Set([...g.planIds, ...g.execIds, ...[...M.cells].filter(([k]) => k.startsWith(`${g.id}|`)).flatMap(([, ids]) => ids)])].filter((id) => drawable(byId.get(id)));
|
|
1078
|
+
const inside = open ? [] : all.filter((id) => !forced(byId.get(id)));
|
|
1079
|
+
for (const id of inside) cardOf.set(id, `genhead:${g.id}`);
|
|
1080
|
+
// A generation the organizing recorded nothing in says `Not organized` and has nothing to open (owner, 2026-09-30).
|
|
1081
|
+
entries.push({ special: 'genhead', id: `genhead:${g.id}`, fold: all.length ? key : null, open: open && all.length > 0, gen: g, slot: { zone: 'genhead', band: g.id, bi, kind: 'gen' }, marks: notesIn(inside), count: all.length });
|
|
1082
|
+
if (open) {
|
|
1083
|
+
for (const id of g.planIds) node(id, head);
|
|
1084
|
+
fold(`exec:${g.id}`, g.execIds, head, `execution decisions · ${g.execIds.length}`, { tip: 'This generation’s execution decisions' });
|
|
1085
|
+
cellsOf(g.id, bi, 'gen');
|
|
1086
|
+
} else {
|
|
1087
|
+
for (const id of all) if (!inside.includes(id)) { const p = M.place.get(id); node(id, p?.zone === 'gen' ? { zone: 'cell', band: g.id, bi, col: p.area ? M.areas.findIndex((a) => a.id === p.area) : A, kind: 'gen' } : head); }
|
|
1088
|
+
}
|
|
1089
|
+
} else if (b.kind === 'plan') {
|
|
1090
|
+
const head = { zone: 'head', band: b.key, bi, kind: 'plan' };
|
|
1091
|
+
node(b.key, head);
|
|
1092
|
+
const ex = M.exec.get(b.key) ?? [];
|
|
1093
|
+
fold(`exec:${b.key}`, ex, head, `execution decisions · ${ex.length}`, { tip: `The decisions that record how ${cleanName(b.plan.label)} was carried out` });
|
|
1094
|
+
cellsOf(b.key, bi, 'plan');
|
|
1095
|
+
} else {
|
|
1096
|
+
entries.push({ special: 'bandhead', id: 'bandhead:none', slot: { zone: 'head', band: NO_PLAN, bi, kind: 'none' } });
|
|
1097
|
+
cellsOf(NO_PLAN, bi, 'none');
|
|
1098
|
+
}
|
|
1099
|
+
});
|
|
1100
|
+
// A work's results, reviews and tests stand under it when it is opened (or in focus, or one of them is selected).
|
|
1101
|
+
for (const n of d.nodes) {
|
|
1102
|
+
if (!FOLDABLE.has(n.category) || !n.parentId || !shown.has(n.parentId)) continue;
|
|
1103
|
+
if (!view.expandedWork.has(n.parentId) && n.id !== view.selectionId && view.focusId !== n.parentId) continue;
|
|
1104
|
+
const at = entries.find((e) => e.id === n.parentId);
|
|
1105
|
+
node(n.id, { ...at.slot, after: n.parentId, indent: 10 });
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const keepAlways = (id) => { const c = byId.get(id)?.category; return c === 'Area' || c === 'Plan'; };
|
|
1109
|
+
// A copy goes or stays with the work item it stands for.
|
|
1110
|
+
const drop = (keep) => { for (const id of [...shown]) if (!keep.has(real(id)) && !keepAlways(real(id))) shown.delete(id); };
|
|
1111
|
+
const cmp = view.scale === 'Compare' ? view.compare : null;
|
|
1112
|
+
if (cmp && cmp.side === 'Delta' && (cmp.items?.length ?? 0) > 0 && !cmp.all) {
|
|
1113
|
+
const diff = new Set(cmp.items.map((i) => i.id));
|
|
1114
|
+
const keep = new Set(diff);
|
|
1115
|
+
for (const id of diff) { const n = byId.get(id); if (n?.areaId) keep.add(n.areaId); if (n?.parentId) keep.add(n.parentId); }
|
|
1116
|
+
drop(keep);
|
|
1117
|
+
}
|
|
1118
|
+
if (filterActive) {
|
|
1119
|
+
const keep = new Set();
|
|
1120
|
+
for (const id of shown) { const n = byId.get(real(id)); if (n && matches(n)) upstreamOf(real(id), d.relations, keep); }
|
|
1121
|
+
if (view.selectionId) keep.add(view.selectionId);
|
|
1122
|
+
drop(keep);
|
|
1123
|
+
}
|
|
1124
|
+
// Results of works no longer drawn go with them.
|
|
1125
|
+
for (const id of [...shown]) { const n = byId.get(id); if (n && FOLDABLE.has(n.category) && n.parentId && !shown.has(n.parentId) && id !== view.selectionId) shown.delete(id); }
|
|
1126
|
+
// Under a filter a fold holds only what the filter leaves out (what matches is drawn in its place), so its card goes
|
|
1127
|
+
// too: the picture shows what matched, in its places, and the bands and columns it stands in.
|
|
1128
|
+
const cards = (e) => e.card && (!filterActive || (e.open && e.ids.some((id) => shown.has(id))));
|
|
1129
|
+
if (filterActive) for (const [id, card] of [...cardOf]) if (!entries.some((e) => e.id === card && e.special)) cardOf.delete(id);
|
|
1130
|
+
return { entries: entries.filter((e) => cards(e) || e.special || shown.has(e.id)), shown, cardOf, byId, copyOf };
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function nodeClasses(n) {
|
|
1134
|
+
const key = colourKey(n.category);
|
|
1135
|
+
const cls = [key === 'intent' ? 'col-intent' : key === 'reality' ? 'col-reality' : 'col-work'];
|
|
1136
|
+
if (n.basis === 'Inferred') cls.push('inferred');
|
|
1137
|
+
if (n.validity === 'Replaced' || n.validity === 'Abandoned') cls.push('replaced');
|
|
1138
|
+
return cls.join(' ');
|
|
1139
|
+
}
|
|
1140
|
+
function edgeClasses(r, part) {
|
|
1141
|
+
const cls = [part];
|
|
1142
|
+
if (part === 'other' && view.links === 'lines') cls.push('all-links');
|
|
1143
|
+
if (r.basis === 'Inferred') cls.push('inferred');
|
|
1144
|
+
if (r.assessment === 'Questioned') cls.push('questioned');
|
|
1145
|
+
if (r.noteCount) cls.push('noted');
|
|
1146
|
+
if (r.noteAttention) cls.push('ask');
|
|
1147
|
+
return cls.join(' ');
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function computeStars() {
|
|
1151
|
+
const r = starredIds(view.data.nodes, view.starMode, view.lastVisit);
|
|
1152
|
+
view.starred = r.ids;
|
|
1153
|
+
view.starNote = r.note;
|
|
1154
|
+
handlers.onStars?.(r);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Where an object stands, in words, for its tooltip: its plan (or generation) and its area; for a work item serving
|
|
1159
|
+
* several areas, where its main one is and why (the owner's rule), and where its copies are. `at`: the area of the
|
|
1160
|
+
* copy the tooltip is for.
|
|
1161
|
+
*/
|
|
1162
|
+
function whereOf(M, id, at = null) {
|
|
1163
|
+
const p = M.place.get(id);
|
|
1164
|
+
// On the whole product (CM; Spec §1.4 "写明为什么"): the Keeper's written reason.
|
|
1165
|
+
if (p?.zone === 'ring' && p.ring === 'whole') return `${WHOLE_LABEL}: ${p.why}`;
|
|
1166
|
+
if (!p || (p.zone !== 'cell' && p.zone !== 'gen')) return '';
|
|
1167
|
+
const name = (x) => cleanName(M.byId.get(x)?.label ?? x);
|
|
1168
|
+
const band = p.gen ? `earlier generation ${M.generations.find((g) => g.id === p.gen)?.name ?? ''}` : p.band === NO_PLAN ? 'not in a plan yet' : name(p.band);
|
|
1169
|
+
const also = M.also.get(id);
|
|
1170
|
+
const plans = also?.plans?.length ? ` · also listed in ${also.plans.map(name).join(', ')}` : '';
|
|
1171
|
+
const mark = sharedMark(M, id, at ?? p.area);
|
|
1172
|
+
// For the whole plan (CN; Spec §1.4 "写明为什么"): the Keeper's written reason.
|
|
1173
|
+
if (p.whole) return `${WHOLE_PLAN_LABEL} — in ${band}, for all of its modules and no single one: ${p.whole}${plans}`;
|
|
1174
|
+
if (!mark) return `In ${band} × ${p.area ? name(p.area) : 'no area yet'}${plans}`;
|
|
1175
|
+
const why = MAIN_BY[mark.by] ? ` (${MAIN_BY[mark.by]})` : '';
|
|
1176
|
+
if (mark.copy) return `Also serves ${name(at)}: a copy. Its main one is in ${band} × ${name(mark.main)}${why}; select it there for its relations${plans}`;
|
|
1177
|
+
return `In ${band} × ${name(p.area)} — its main one${why}; also serves ${mark.others.map(name).join(', ')}, drawn there dashed${plans}`;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function render(opts = {}) {
|
|
1181
|
+
const d = view.data;
|
|
1182
|
+
if (!d) return;
|
|
1183
|
+
computeStars();
|
|
1184
|
+
const M = placementOf(d, { showReplaced: view.showReplaced });
|
|
1185
|
+
const { entries, shown, cardOf, byId, copyOf } = visibleSet(M);
|
|
1186
|
+
const A = M.areas.length;
|
|
1187
|
+
const W = storyWidth(A);
|
|
1188
|
+
// Which relations are drawn how, and the link count beside each object (D54).
|
|
1189
|
+
const rels = d.relations.filter((r) => shown.has(r.from) && shown.has(r.to));
|
|
1190
|
+
const part = new Map(rels.map((r) => [r.id, relationPart(r, byId.get(r.from), byId.get(r.to), M)]));
|
|
1191
|
+
const links = new Map();
|
|
1192
|
+
for (const r of rels) if (part.get(r.id) === 'other') for (const id of [r.from, r.to]) links.set(id, (links.get(id) ?? 0) + 1);
|
|
1193
|
+
// The ring's folded group of objects reaching several areas: one line from its card to each area it reaches, with
|
|
1194
|
+
// how many reach it — the relations themselves are inside the fold (D54, D100: "跨模块 … 到它落到的各列画关系线").
|
|
1195
|
+
const synthetic = [];
|
|
1196
|
+
for (const e of entries) {
|
|
1197
|
+
if (!e.card || e.key !== 'ring:multi' || e.open) continue;
|
|
1198
|
+
const per = new Map();
|
|
1199
|
+
for (const id of e.inside) for (const a of M.place.get(id)?.areas ?? []) per.set(a, (per.get(a) ?? 0) + 1);
|
|
1200
|
+
for (const [a, k] of per) if (shown.has(a)) {
|
|
1201
|
+
synthetic.push({ data: { id: `ringlink:${a}`, source: e.id, target: a, type: 'refines', part: 'other', synthetic: true, elabel: `${k} reach${k === 1 ? 'es' : ''} ${cleanName(byId.get(a)?.label ?? '')}`, mark: '', fromLabel: e.label, toLabel: byId.get(a)?.label }, classes: `other agg${view.links === 'lines' ? ' all-links' : ''}` });
|
|
1202
|
+
for (const id of [e.id, a]) links.set(id, (links.get(id) ?? 0) + 1);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
const cells = observedCells(d, view.showReplaced, M);
|
|
1206
|
+
const elements = [];
|
|
1207
|
+
const slotById = new Map(entries.map((e) => [e.id, e.slot]));
|
|
1208
|
+
// The process layer (CKC-24): nothing when the endpoint is not built yet, so the picture is exactly as before.
|
|
1209
|
+
// `cards` says which card holds each folded object this pass, so a lit item on a folded-away object hangs on its card.
|
|
1210
|
+
const procBits = procLayer?.elements?.({ view, shown, byId, cards: cardOf }) ?? null;
|
|
1211
|
+
const procAlerts = procLayer?.alertIds?.({ shown, byId, cards: cardOf }) ?? null;
|
|
1212
|
+
// The pictures of an object: its words over everything; a theme's material, when there is one, under the border.
|
|
1213
|
+
const withImage = (data, over, material = null) => {
|
|
1214
|
+
const pics = material ? [over, material] : [over];
|
|
1215
|
+
return { ...data, imgs: pics.map((q) => q.uri), imgWs: pics.map((q) => q.w), imgHs: pics.map((q) => q.h), imgOxs: pics.map((q) => q.ox), imgOys: pics.map((q) => q.oy) };
|
|
1216
|
+
};
|
|
1217
|
+
const fmtDay = (o) => (o?.at ? String(o.at).slice(0, 10) : '');
|
|
1218
|
+
for (const e of entries) {
|
|
1219
|
+
if (e.card) {
|
|
1220
|
+
const [w, h] = SIZE.group;
|
|
1221
|
+
const words = e.key === OWNER_WORDS;
|
|
1222
|
+
const label = e.open ? `${e.label} · click to fold` : e.label;
|
|
1223
|
+
const chip = links.get(e.id) ?? 0;
|
|
1224
|
+
elements.push({ data: withImage({ id: e.id, rawLabel: e.label, tip: e.tip ?? e.label, why: e.why ?? null, kind: 'group', category: 'group', fold: e.key, group: words ? OWNER_WORDS : null, open: e.open, count: e.count, marks: e.marks, slot: e.slot, areaId: null, color: palette.groupBorder, w, h, overTop: foldMarks(e.marks).length ? OVER.top : 0, overRight: chip ? OVER.right : 0 }, groupImage(label, e.count, w, h, e.open, e.open ? null : e.marks, view.links === 'counts' ? chip : 0)), classes: 'group' });
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
if (e.special === 'genhead') {
|
|
1228
|
+
const g = e.gen, w = W - 2 * EDGE, h = SIZE.genhead[1];
|
|
1229
|
+
const caret = e.fold ? `${e.open ? '▾' : '▸'} ` : '';
|
|
1230
|
+
const lines = [[`${caret}EARLIER GENERATION · ${g.name}`, 'kind'], [`${destinationLine(g.destinations)}${g.ended ? ` · ended ${fmtDay(g.ended)}` : ''}${g.endedBy?.label ? ` by ${g.endedBy.label}` : ''}`, 'muted']];
|
|
1231
|
+
const tip = `Earlier generation · ${g.name}: ${g.organized ? destinationLine(g.destinations) : `${destinationLine(g.destinations)} — the organizing has not recorded its items or where each went`}${g.endedBy?.line ? ` — ended by ${g.endedBy.line}` : ''}`;
|
|
1232
|
+
elements.push({ data: withImage({ id: e.id, rawLabel: g.name, tip, kind: 'genhead', category: 'genhead', fold: e.fold, open: e.open, count: e.fold ? e.count : null, marks: e.marks, slot: e.slot, areaId: null, color: palette.groupBorder, w, h, overTop: foldMarks(e.marks).length ? OVER.top : 0 }, cardImage(lines, w, h, { marks: e.open ? null : e.marks })), classes: 'card' });
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
if (e.special === 'bandhead') {
|
|
1236
|
+
const [w, h] = SIZE.item, u = M.unplaced;
|
|
1237
|
+
const lines = [['NOT IN A PLAN', 'kind'], [`${u.workNoPlan} work item${u.workNoPlan === 1 ? '' : 's'} not placed yet`, 'muted'], [u.workNeither ? `${u.workNeither} with no area either` : 'counted by area below', 'faint']];
|
|
1238
|
+
elements.push({ data: withImage({ id: e.id, rawLabel: 'Not in a plan', tip: `Not in a plan: ${u.workNoPlan} work items no record read so far places in a plan, counted in the column of their area`, kind: 'bandhead', category: 'bandhead', slot: e.slot, areaId: null, color: palette.groupBorder, w, h }, cardImage(lines, w, h)), classes: 'card' });
|
|
1239
|
+
continue;
|
|
1240
|
+
}
|
|
1241
|
+
if (e.special === 'colhead') {
|
|
1242
|
+
const [w, h] = SIZE.heading;
|
|
1243
|
+
const lines = [['CROSS-CUTTING', 'kind'], ['no one area — not one-to-one', 'muted'], ['with the columns', 'muted']];
|
|
1244
|
+
elements.push({ data: withImage({ id: e.id, rawLabel: 'Cross-cutting', tip: 'Cross-cutting: what serves no one area, or is not placed on an area yet. It does not map one-to-one to the columns.', kind: 'colhead', category: 'colhead', slot: e.slot, areaId: null, color: palette.intent, w, h }, cardImage(lines, w, h, { border: palette.intent })), classes: 'card' });
|
|
1245
|
+
continue;
|
|
1246
|
+
}
|
|
1247
|
+
const n = byId.get(e.copyOf ?? e.id);
|
|
1248
|
+
const [w, h] = sizeOf(n);
|
|
1249
|
+
// A copy carries no link count: its relations are drawn from the work item's own element (owner, 2026-09-30).
|
|
1250
|
+
const chip = view.links === 'counts' && !e.copyOf ? links.get(n.id) ?? 0 : 0;
|
|
1251
|
+
// A copy says where its main one is; a work of an earlier generation where it went (Spec §2.12, D100); any other
|
|
1252
|
+
// folded work its four things.
|
|
1253
|
+
const copyArea = e.copyOf ? (e.slot.col < A ? M.areas[e.slot.col].id : null) : null;
|
|
1254
|
+
const shared = e.copyOf ? sharedMark(M, n.id, copyArea) : null;
|
|
1255
|
+
const dest = M.destinationOf.get(n.id);
|
|
1256
|
+
// A cross-cutting foundation's column head keeps the project's name and says what it is on its second line (D101).
|
|
1257
|
+
// Work for its whole plan says so on its second line; the reason is in its tooltip (CN).
|
|
1258
|
+
const whole = M.place.get(n.id)?.whole ?? null;
|
|
1259
|
+
const procLine = shared ? shared.text : dest ? destinationText(dest) : n.category === 'Area' && n.foundation ? FOUNDATION_LABEL : whole ? `${WHOLE_PLAN_LABEL} · ${whole}` : procLayer?.foldLineOf?.(n.id) ?? null;
|
|
1260
|
+
// What is open on a work stays on it, folded or not (D75): a count on its top edge. A copy leaves it to the main one.
|
|
1261
|
+
const openMark = e.copyOf ? null : procLayer?.openMarkOf?.(n.id) ?? null;
|
|
1262
|
+
const img = objectImage(n, w, h, { starred: view.starred.has(n.id), areaWork: M.areaWork.get(n.id), links: chip, procLine, open: openMark });
|
|
1263
|
+
// What stands out of the object — corner marks above it, a link count to its right — so fitting the picture takes it in.
|
|
1264
|
+
const over = { overTop: marksOf(n, view.starred.has(n.id)).length || n.basis === 'Inferred' || openMark ? OVER.top : 0, overRight: chip ? OVER.right : 0 };
|
|
1265
|
+
const colour = COLUMN_COLOR[colourKey(n.category)];
|
|
1266
|
+
let cls = nodeClasses(n);
|
|
1267
|
+
if (procAlerts?.has(n.id)) cls += ' proc-alert';
|
|
1268
|
+
if (e.copyOf) cls += ' copy';
|
|
1269
|
+
const where = e.copyOf ? whereOf(M, n.id, copyArea) : whereOf(M, n.id);
|
|
1270
|
+
elements.push({ data: withImage({ id: e.id, rawLabel: n.label, category: n.category, areaId: e.copyOf ? copyArea : M.place.get(n.id)?.area ?? n.areaId ?? null, parentId: n.parentId ?? null, progress: n.progress ?? null, color: colour, links: e.copyOf ? 0 : links.get(n.id) ?? 0, where, slot: e.slot, w, h, ...(e.copyOf ? { copyOf: n.id } : {}), ...(procLine ? { line2: procLine } : {}), ...(openMark ? { openText: openMark.text } : {}), ...(!e.copyOf && procLayer?.expandable?.(n.id) ? { steps: procLayer.isOpen?.(n.id) ? 'open' : 'folded' } : {}), ...over }, img, materialPicture(shapeOf(n.category), w, h, colour)), classes: cls });
|
|
1271
|
+
}
|
|
1272
|
+
if (view.scale !== 'Compare') {
|
|
1273
|
+
for (const [key, cell] of cells) {
|
|
1274
|
+
if (key !== null && !shown.has(key)) continue;
|
|
1275
|
+
const [w, h] = SIZE.folder;
|
|
1276
|
+
const col = key === null ? A : M.areas.findIndex((a) => a.id === key);
|
|
1277
|
+
elements.push({ data: withImage({ id: `folder:${key ?? 'project-wide'}`, kind: 'folder', category: 'folder', folder: key, areaId: key, total: cell.total, summary: cellSummary(cell), rawLabel: 'Observed reality', slot: { zone: 'folder', col }, color: COLUMN_COLOR.reality, w, h }, folderImage(cell, w, h)), classes: 'folder' });
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
// The branches of the process view: step nodes and send-back arrows of the works that are open (CKC-24). A step
|
|
1281
|
+
// stands under its work, in the work's own place; so does a send-back on a card, under the card.
|
|
1282
|
+
const procNodes = [], procEdges = [];
|
|
1283
|
+
if (procBits) for (const el of procBits.elements) {
|
|
1284
|
+
if (el.data.source) { procEdges.push(el); continue; }
|
|
1285
|
+
const at = slotById.get(el.data.procOf);
|
|
1286
|
+
if (!at) continue;
|
|
1287
|
+
procNodes.push({ ...el, data: { ...el.data, slot: { ...at, after: el.data.procOf, indent: el.data.kind === 'proc-suggest' ? 16 : 8 } } });
|
|
1288
|
+
}
|
|
1289
|
+
elements.push(...procNodes);
|
|
1290
|
+
const L = storyLayout(elements.map((x) => x.data));
|
|
1291
|
+
// The bands and the ring, as the layout measured them: backgrounds under what they hold.
|
|
1292
|
+
if (L) {
|
|
1293
|
+
for (const b of L.bands) elements.push({ data: { id: `band:${b.key}`, kind: 'band', category: 'band', rawLabel: '', w: b.w, h: b.h, imgs: [EMPTY_PIC.uri], imgWs: [1], imgHs: [1], imgOxs: [0], imgOys: [0] }, classes: `band ${b.key === NO_PLAN ? 'none' : M.bandOrder.has(b.key) && M.bands[M.bandOrder.get(b.key)].kind === 'plan' ? 'plan' : 'gen'}` });
|
|
1294
|
+
if (L.ring) { const t = titlePicture('CROSS-CUTTING · across the areas, not one-to-one with the columns above', palette.intent); elements.push({ data: { id: 'ring:cross', kind: 'ring', category: 'ring', rawLabel: '', w: L.ring.w, h: L.ring.h, imgs: [t.uri], imgWs: [t.w], imgHs: [t.h], imgOxs: [0], imgOys: [0] }, classes: 'ring' }); }
|
|
1295
|
+
}
|
|
1296
|
+
const nodeIds = new Set(elements.map((x) => x.data.id));
|
|
1297
|
+
for (const r of rels) {
|
|
1298
|
+
const p = part.get(r.id);
|
|
1299
|
+
const assessment = r.assessment || 'Not assessed';
|
|
1300
|
+
elements.push({ data: { id: r.id, source: r.from, target: r.to, type: r.type, part: p, elabel: `${r.type} · ${assessment}${r.noteCount ? ' ❓' : ''}`, mark: r.noteCount ? '❓' : '', fromLabel: byId.get(r.from)?.label, toLabel: byId.get(r.to)?.label, claim: r.claim }, classes: edgeClasses(r, p) });
|
|
1301
|
+
}
|
|
1302
|
+
elements.push(...synthetic);
|
|
1303
|
+
for (const el of procEdges) if (nodeIds.has(el.data.source) && nodeIds.has(el.data.target)) elements.push(el);
|
|
1304
|
+
const key = elements.map((e) => e.data.id).sort().join('|');
|
|
1305
|
+
const zoom = cy.zoom(); const pan = { ...cy.pan() };
|
|
1306
|
+
// The graph is updated by difference, not rebuilt. Removing every element and adding it back on each poll made
|
|
1307
|
+
// the whole picture blink while the Keeper was working (owner, 2026-09-17: "网页还是在一闪一闪的"), because every
|
|
1308
|
+
// node's picture was drawn again even when nothing about it had changed.
|
|
1309
|
+
const desired = new Map(elements.map((e) => [e.data.id, e]));
|
|
1310
|
+
const signature = (e) => `${JSON.stringify(e.data)}|${e.classes ?? ''}`;
|
|
1311
|
+
let dataChanged = false;
|
|
1312
|
+
cy.batch(() => {
|
|
1313
|
+
const gone = cy.elements().filter((el) => !desired.has(el.id()));
|
|
1314
|
+
if (gone.length > 0) { gone.remove(); dataChanged = true; }
|
|
1315
|
+
const added = [];
|
|
1316
|
+
for (const [id, e] of desired) {
|
|
1317
|
+
const el = cy.getElementById(id);
|
|
1318
|
+
const sig = signature(e);
|
|
1319
|
+
if (el.empty()) { added.push(e); continue; }
|
|
1320
|
+
if (el.scratch('_sig') === sig) continue;
|
|
1321
|
+
dataChanged = true;
|
|
1322
|
+
el.data(e.data);
|
|
1323
|
+
el.classes(e.classes ?? '');
|
|
1324
|
+
el.scratch('_sig', sig);
|
|
1325
|
+
}
|
|
1326
|
+
// A drag that starts on an object moves the picture, like a drag on the background (cytoscape: `pannable`). At the
|
|
1327
|
+
// zoom floor a large project is mostly objects, and dragging is how the rest of it is reached (CKC-09 AC-39);
|
|
1328
|
+
// places are worked out, not arranged by hand (Spec §6.3), so an object is not something to drag about.
|
|
1329
|
+
if (added.length > 0) { dataChanged = true; cy.add(added).forEach((el) => el.scratch('_sig', signature(desired.get(el.id())))).panify(); }
|
|
1330
|
+
});
|
|
1331
|
+
const emphKey = emphasisKey();
|
|
1332
|
+
if (!dataChanged && key === lastKey && !opts.refit && !opts.relayout) {
|
|
1333
|
+
if (emphKey !== lastEmph) { applyEmphasis(); lastEmph = emphKey; }
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
// Places are worked out from the model every time (Spec §6.3 "布局"): the same data gives the same picture, so an
|
|
1337
|
+
// update that changes nothing about an object's place leaves it where it was.
|
|
1338
|
+
if (L) {
|
|
1339
|
+
cy.batch(() => cy.nodes().forEach((n) => {
|
|
1340
|
+
const id = n.id();
|
|
1341
|
+
if (id.startsWith('band:')) { const b = L.bands.find((x) => `band:${x.key}` === id); if (b) n.position({ x: b.x + b.w / 2, y: b.y + b.h / 2 }); return; }
|
|
1342
|
+
if (id === 'ring:cross') { n.position({ x: L.ring.x + L.ring.w / 2, y: L.ring.y + L.ring.h / 2 }); return; }
|
|
1343
|
+
const b = L.box.get(id);
|
|
1344
|
+
if (b) n.position({ x: b.x + b.w / 2, y: b.y + b.h / 2 });
|
|
1345
|
+
}));
|
|
1346
|
+
layoutInfo = L;
|
|
1347
|
+
} else if (key !== lastKey) {
|
|
1348
|
+
const layout = cy.layout({ name: 'dagre', rankDir: 'BT', nodeSep: 28, rankSep: 56, edgeSep: 12, padding: 30, animate: false, fit: false, ranker: 'network-simplex', edgeWeight: (e) => (e.data('type') === 'serves' || e.data('type') === 'refines' ? 3 : 1) });
|
|
1349
|
+
layout.run();
|
|
1350
|
+
layoutInfo = null;
|
|
1351
|
+
}
|
|
1352
|
+
if (firstLayout) fitReadable();
|
|
1353
|
+
else { cy.zoom(zoom); cy.pan(pan); } // a re-layout never moves the owner's viewport under them
|
|
1354
|
+
lastKey = key;
|
|
1355
|
+
firstLayout = false;
|
|
1356
|
+
setZoomLimit(); // the picture may have grown or shrunk
|
|
1357
|
+
routeEdges();
|
|
1358
|
+
if (opts.refit && cy.nodes().length) fitReadable();
|
|
1359
|
+
applyEmphasis();
|
|
1360
|
+
lastEmph = emphKey;
|
|
1361
|
+
checkReadability();
|
|
1362
|
+
handlers.onLegend?.(legendItems());
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
/**
|
|
1367
|
+
* Right-angled routes (D54): structure lines leave upwards to a shared bus and rise into their target; a project-wide
|
|
1368
|
+
* plan's line to the product runs up the right-hand margin, above the goals; an inferred own-area line runs up the
|
|
1369
|
+
* gap on the column's left to its area. Cytoscape draws them as segments through these points.
|
|
1370
|
+
*/
|
|
1371
|
+
function routeEdges() {
|
|
1372
|
+
const L = layoutInfo;
|
|
1373
|
+
const ROUTE = 'curve-style edge-distances segment-weights segment-distances segment-radius';
|
|
1374
|
+
cy.batch(() => cy.edges().forEach((e) => {
|
|
1375
|
+
const p = e.data('part');
|
|
1376
|
+
const s = e.source().position(), t = e.target().position(), th = e.target().data('h');
|
|
1377
|
+
let pts = null;
|
|
1378
|
+
if (L && p === 'structure') {
|
|
1379
|
+
const bus = t.y + th / 2 + LAYOUT.bus;
|
|
1380
|
+
if (e.source().data('category') === 'Plan') {
|
|
1381
|
+
// A project-wide plan's line runs up the right-hand margin, so it never crosses the goals row (D54).
|
|
1382
|
+
const x = L.width - 10;
|
|
1383
|
+
pts = e.target().data('category') === 'Product' ? [{ x, y: s.y }, { x, y: t.y }] : [{ x, y: s.y }, { x, y: bus }, { x: t.x, y: bus }];
|
|
1384
|
+
} else pts = [{ x: s.x, y: bus }, { x: t.x, y: bus }];
|
|
1385
|
+
} else if (L && p === 'own') {
|
|
1386
|
+
const ci = L.keys.indexOf(e.target().id());
|
|
1387
|
+
if (ci >= 0) { const x = L.colX[ci] - 7; pts = [{ x, y: s.y }, { x, y: t.y + th / 4 }]; }
|
|
1388
|
+
}
|
|
1389
|
+
const key = pts ? pts.map((q) => `${Math.round(q.x)},${Math.round(q.y)}`).join(' ') : '';
|
|
1390
|
+
if (e.scratch('_route') === key) return;
|
|
1391
|
+
e.scratch('_route', key);
|
|
1392
|
+
if (!pts) { e.removeStyle(ROUTE); return; }
|
|
1393
|
+
// cytoscape places segment points by weight along the line between the two centres and by distance across it.
|
|
1394
|
+
const dx = t.x - s.x, dy = t.y - s.y, len = Math.hypot(dx, dy) || 1, ux = dx / len, uy = dy / len;
|
|
1395
|
+
const weights = pts.map((q) => (((q.x - s.x) * ux + (q.y - s.y) * uy) / len).toFixed(4)).join(' ');
|
|
1396
|
+
const distances = pts.map((q) => ((q.x - s.x) * -uy + (q.y - s.y) * ux).toFixed(2)).join(' ');
|
|
1397
|
+
e.style({ 'curve-style': 'round-segments', 'edge-distances': 'node-position', 'segment-weights': weights, 'segment-distances': distances, 'segment-radius': 6 });
|
|
1398
|
+
}));
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
function checkReadability() {
|
|
1402
|
+
if (!handlers.onReadability) return;
|
|
1403
|
+
const nodes = pictureNodes();
|
|
1404
|
+
// Only lines that are drawn right now, along the path they are drawn on (D46: the check reads the picture as shown).
|
|
1405
|
+
const edges = cy.edges().filter((e) => e.visible()).map((e) => {
|
|
1406
|
+
const pts = [e.sourceEndpoint(), ...(e.segmentPoints() ?? e.controlPoints() ?? []), e.targetEndpoint()].filter(Boolean).map((q) => ({ x: q.x, y: q.y }));
|
|
1407
|
+
return { id: e.id(), source: e.data('source'), target: e.data('target'), points: pts };
|
|
1408
|
+
});
|
|
1409
|
+
// Judged for the window the viewport is fitted to; a window with no size yet is judged once it has one (onResize).
|
|
1410
|
+
const size = windowSize();
|
|
1411
|
+
if (size.w < 2 || size.h < 2) return;
|
|
1412
|
+
handlers.onReadability(readabilityReport(nodes, edges, size, fitOpts()));
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/** The legend lists only what the picture shows right now, with counts (D53). */
|
|
1416
|
+
function legendItems() {
|
|
1417
|
+
const nodes = cy.nodes();
|
|
1418
|
+
const count = (sel) => nodes.filter(sel).length;
|
|
1419
|
+
const items = [];
|
|
1420
|
+
const intent = count((n) => colourKey(n.data('category')) === 'intent' && !n.data('kind') && CATEGORY_COLUMN[n.data('category')]);
|
|
1421
|
+
const plans = count((n) => n.data('category') === 'Plan');
|
|
1422
|
+
const works = count((n) => n.data('category') === 'Work item' && !n.hasClass('copy')); // each once; its copies below
|
|
1423
|
+
const results = count((n) => FOLDABLE.has(n.data('category')));
|
|
1424
|
+
const folderTotal = nodes.filter((n) => n.data('kind') === 'folder').reduce((s, n) => s + (n.data('total') ?? 0), 0);
|
|
1425
|
+
if (intent) items.push({ icon: 'doc', color: COLUMN_COLOR.intent, label: 'Product intent', count: intent, desc: 'the owner\'s words, product, goals, areas, requirements, designs, decisions; the kind is written on each' });
|
|
1426
|
+
if (plans) items.push({ icon: 'hex', color: COLUMN_COLOR.plan, label: 'Plan', count: plans, desc: 'plans and milestones' });
|
|
1427
|
+
if (works) items.push({ icon: 'hex', color: COLUMN_COLOR.work, label: 'Work item', count: works, desc: 'tasks, contracts, issues and other work' });
|
|
1428
|
+
if (folderTotal || nodes.some((n) => n.data('kind') === 'folder')) items.push({ icon: 'folder', color: COLUMN_COLOR.reality, label: 'Observed reality', count: folderTotal, desc: 'results, reviews and tests of each column\'s work; click a folder to open the same cell in List' });
|
|
1429
|
+
if (results) items.push({ icon: 'ellipse', color: COLUMN_COLOR.reality, label: 'Result, review or test', count: results, desc: 'drawn one by one when a work item is opened' });
|
|
1430
|
+
items.push({ sep: true });
|
|
1431
|
+
const drawn = cy.edges().filter((e) => e.visible());
|
|
1432
|
+
const structure = drawn.filter((e) => e.data('part') === 'structure').length;
|
|
1433
|
+
if (structure) items.push({ icon: 'line', label: 'structure', count: structure, desc: 'goal → product, area → goal, project-wide plan → product; work stands in its area\'s column instead of one line each' });
|
|
1434
|
+
const others = cy.edges().filter((e) => e.data('part') === 'other').length;
|
|
1435
|
+
if (others) items.push(view.links === 'counts' ? { icon: 'count', label: 'other links', count: others, desc: 'between areas, or between work items: a count beside each end; point at or select an object to draw its links, or switch to draw them all' } : { icon: 'line', label: 'other links', count: others, desc: 'between areas, or between work items' });
|
|
1436
|
+
const inferredLines = drawn.filter((e) => e.hasClass('inferred')).length, inferredObjects = count((n) => n.hasClass('inferred'));
|
|
1437
|
+
if (inferredLines) items.push({ icon: 'dotted', label: 'Inferred relation', count: inferredLines, desc: 'the Keeper\'s own inference, not stated in the material: a dotted line; a work item placed in its area by inference is tied to it by a dotted line' });
|
|
1438
|
+
if (inferredObjects) items.push({ icon: 'inferred', label: 'Inferred object', count: inferredObjects, desc: 'the Keeper\'s own inference, not stated in the material: the word Inferred on the object\'s top edge; its frame stays solid' });
|
|
1439
|
+
// A work item serving several modules stands in each (owner, 2026-09-30): solid in its main one, dashed in the others.
|
|
1440
|
+
const copies = count((n) => n.hasClass('copy'));
|
|
1441
|
+
if (copies) items.push({ icon: 'dashed', color: COLUMN_COLOR.work, label: 'also serves this module', count: copies, desc: 'a pale card with a dashed frame: the work item also serves this module and its main one (solid, full strength) is in another — the module of the contract it implements, else the one its ticket or prompt names; it says where on its second line, and its relations are drawn from the main one' });
|
|
1442
|
+
const steps = count((n) => n.data('kind') === 'proc-step');
|
|
1443
|
+
if (steps) items.push({ icon: 'hex', color: COLUMN_COLOR.work, label: 'Process step', count: steps, desc: 'one step of a work’s process, grown by clicking the work; every work starts folded, with the count of its steps and of what is open on it' });
|
|
1444
|
+
const sug = count((n) => n.data('kind') === 'proc-suggest');
|
|
1445
|
+
if (sug) items.push({ icon: 'ellipse', color: palette.danger, label: 'Send-back still open', count: sug, desc: 'a send-back not yet closed — Suggested or Returned — pointing back to work or plan; click it for the text and Copy for agent' });
|
|
1446
|
+
const procEdges = drawn.filter((e) => e.data('part') === 'proc').length;
|
|
1447
|
+
if (procEdges) items.push({ icon: 'line', label: 'process', count: procEdges, desc: 'the chain of a work’s steps; a dashed red line is a send-back going back, a dotted one points at another area’s work' });
|
|
1448
|
+
const questioned = drawn.filter((e) => e.hasClass('questioned')).length;
|
|
1449
|
+
if (questioned) items.push({ icon: 'questioned', label: 'Questioned', count: questioned, desc: 'the Keeper questions this relation; always drawn' });
|
|
1450
|
+
const groups = count((n) => (n.data('kind') === 'group' || n.data('kind') === 'genhead') && n.data('fold') && !n.data('open'));
|
|
1451
|
+
if (groups) items.push({ icon: 'stack', label: 'folded', count: groups, desc: 'a group, a full cell or an earlier generation, shown as a stack with its count; click to open it' });
|
|
1452
|
+
// The story map's own drawings (D100): the plans' bands and the cross-cutting ring.
|
|
1453
|
+
const bands = count((n) => n.hasClass('band'));
|
|
1454
|
+
if (bands) items.push({ icon: 'band', color: COLUMN_COLOR.plan, label: 'band', count: bands, desc: 'a plan across the columns, its head on the left; above them the earlier generations, below them what is in no plan yet' });
|
|
1455
|
+
if (count((n) => n.hasClass('ring'))) items.push({ icon: 'ring', color: COLUMN_COLOR.intent, label: 'cross-cutting', count: 0, desc: 'what reaches several areas or none: it does not map one-to-one to the columns above; the rightmost column holds each band’s work with no area' });
|
|
1456
|
+
const byMark = new Map();
|
|
1457
|
+
for (const n of view.data.nodes) {
|
|
1458
|
+
if (!cy.getElementById(n.id).length) continue;
|
|
1459
|
+
for (const m of marksOf(n, view.starred.has(n.id))) byMark.set(m, (byMark.get(m) ?? 0) + 1);
|
|
1460
|
+
}
|
|
1461
|
+
if (byMark.size) items.push({ sep: true });
|
|
1462
|
+
for (const m of [MARKS.star, MARKS.flag, MARKS.ask, MARKS.note, MARKS.pending]) if (byMark.get(m)) items.push({ icon: 'mark', mark: m, label: m.legend, count: byMark.get(m), desc: m.desc });
|
|
1463
|
+
return items;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// Draw an object's relations while it is pointed at or selected (D54), with their type and assessment on the line.
|
|
1467
|
+
function lightLinks() {
|
|
1468
|
+
cy.batch(() => {
|
|
1469
|
+
cy.edges('.lit').removeClass('lit');
|
|
1470
|
+
for (const id of [view.selectionId, hoverId]) {
|
|
1471
|
+
if (!id) continue;
|
|
1472
|
+
const el = cy.getElementById(id);
|
|
1473
|
+
if (el.length && el.isNode()) el.connectedEdges().addClass('lit');
|
|
1474
|
+
}
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// The whole picture in the window, never so small that its names stop being readable and never magnified. When it
|
|
1479
|
+
// does not fit at the floor it starts at its top left — the top levels over the first column — and the owner drags
|
|
1480
|
+
// to the rest (graph-fit.js fitView). This is the graph's own view: it is worked out again when the window changes.
|
|
1481
|
+
function fitReadable() {
|
|
1482
|
+
auto = true;
|
|
1483
|
+
chosen = null;
|
|
1484
|
+
const size = windowSize();
|
|
1485
|
+
const v = size.w < 2 || size.h < 2 ? null : openingView(pictureNodes(), size, fitOpts());
|
|
1486
|
+
if (!v) return; // nothing to show, or no window yet: fitted when there is one (onResize)
|
|
1487
|
+
cy.stop();
|
|
1488
|
+
cy.viewport({ zoom: v.zoom, pan: v.pan });
|
|
1489
|
+
chosen = { zoom: cy.zoom(), pan: { ...cy.pan() } };
|
|
1490
|
+
seen = size;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
/** A work item's dashed copies in the other areas it serves. */
|
|
1494
|
+
const copiesOf = (id) => cy.nodes('.copy').filter((n) => n.data('copyOf') === id);
|
|
1495
|
+
function applyEmphasis() {
|
|
1496
|
+
cy.elements().removeClass('faded related hit focus near diff-added diff-removed diff-changed copy-sel');
|
|
1497
|
+
for (const id of pinned) { const el = cy.getElementById(id); if (el.length) el.addClass('hit'); copiesOf(id).addClass('hit'); }
|
|
1498
|
+
const sel = view.selectionId ? cy.getElementById(view.selectionId) : null;
|
|
1499
|
+
if (sel && sel.length) {
|
|
1500
|
+
if (!sel.selected()) { cy.elements().unselect(); sel.select(); }
|
|
1501
|
+
} else if (cy.$(':selected').length) {
|
|
1502
|
+
cy.elements().unselect();
|
|
1503
|
+
}
|
|
1504
|
+
// Selecting a copy selects the work item; its copies say so too.
|
|
1505
|
+
if (view.selectionId) copiesOf(view.selectionId).addClass('copy-sel');
|
|
1506
|
+
lightLinks();
|
|
1507
|
+
const cmp2 = view.scale === 'Compare' ? view.compare : null;
|
|
1508
|
+
if (cmp2 && (cmp2.items?.length ?? 0) > 0) {
|
|
1509
|
+
const here = cmp2.items[cmp2.index ?? 0];
|
|
1510
|
+
const byKind = new Map(cmp2.items.map((i) => [i.id, i.kind]));
|
|
1511
|
+
cy.nodes().not('.band, .ring').forEach((n) => {
|
|
1512
|
+
const kind = byKind.get(n.data('copyOf') ?? n.id());
|
|
1513
|
+
if (!kind) { n.addClass('near'); return; }
|
|
1514
|
+
n.addClass(kind === 'Added' ? 'diff-added' : kind === 'Removed' ? 'diff-removed' : 'diff-changed');
|
|
1515
|
+
});
|
|
1516
|
+
if (here) cy.getElementById(here.id).addClass('focus');
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
if (view.scale === 'Work' && view.focusId) {
|
|
1520
|
+
const rel = view.data.relations;
|
|
1521
|
+
const keep = new Set([...upstreamOf(view.focusId, rel, new Set()), ...downstreamOf(view.focusId, rel, new Set())]);
|
|
1522
|
+
// The steps of a kept work's open branch are part of what the owner is reading (CKC-24).
|
|
1523
|
+
cy.nodes().forEach((n) => { const p = n.data('procOf'); if (p && keep.has(p)) keep.add(n.id()); });
|
|
1524
|
+
cy.nodes().not('.band, .ring').forEach((n) => { const id = n.data('copyOf') ?? n.id(); if (!keep.has(id)) n.addClass('faded'); else if (id !== view.focusId) n.addClass('near'); });
|
|
1525
|
+
cy.getElementById(view.focusId).addClass('focus');
|
|
1526
|
+
cy.edges().forEach((e) => { if (!keep.has(e.source().id()) || !keep.has(e.target().id())) e.addClass('faded'); else e.addClass('related'); });
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* Open the folds that hide a node, from its place on the story map: the owner's words, its column's requirements,
|
|
1532
|
+
* designs and decisions, the ring's group, its plan's execution decisions, its cell's stacks, its rolled generation;
|
|
1533
|
+
* a result's work item (and that work's folds).
|
|
1534
|
+
*/
|
|
1535
|
+
function unfold(id, M = placementOf(view.data, { showReplaced: view.showReplaced }), depth = 0) {
|
|
1536
|
+
const n = view.data?.nodes.find((x) => x.id === id);
|
|
1537
|
+
if (!n || depth > 2) return;
|
|
1538
|
+
const p = M.place.get(id);
|
|
1539
|
+
const open = (k) => view.expandedGroups.add(k);
|
|
1540
|
+
if (p?.gen) open(`gen:${p.gen}`);
|
|
1541
|
+
switch (p?.zone) {
|
|
1542
|
+
case 'words': open(OWNER_WORDS); break;
|
|
1543
|
+
case 'intent': open(`intent:${p.area}`); break;
|
|
1544
|
+
case 'ring': open(`ring:${p.ring}`); break;
|
|
1545
|
+
case 'exec': open(`exec:${p.band}`); if (M.generations.some((g) => g.id === p.band)) open(`gen:${p.band}`); break;
|
|
1546
|
+
case 'gen-plan': open(`gen:${p.band}`); break;
|
|
1547
|
+
case 'cell': case 'gen': { const k = cellKey(p.band, p.area); if (!p.whole) open(`cell:${k}`); open(`more:${k}`); open(`found:${k}`); break; }
|
|
1548
|
+
default: break;
|
|
1549
|
+
}
|
|
1550
|
+
if (n.parentId && FOLDABLE.has(n.category)) { view.expandedWork.add(n.parentId); unfold(n.parentId, M, depth + 1); }
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
return {
|
|
1554
|
+
cy,
|
|
1555
|
+
view,
|
|
1556
|
+
update(data) { view.data = data; render(); },
|
|
1557
|
+
setStars(mode, lastVisit) { view.starMode = mode; view.lastVisit = lastVisit; render(); },
|
|
1558
|
+
setSelection(id) { if (anchorAt?.id !== id) anchorAt = null; view.selectionId = id; if (id && view.data && !cy.getElementById(id).length) render(); else { applyEmphasis(); lastEmph = emphasisKey(); } },
|
|
1559
|
+
/**
|
|
1560
|
+
* What `Compare` is showing: the differing objects, which side (`Before`, `Delta`, `After`) and which difference
|
|
1561
|
+
* the owner is standing on. `Delta` draws only what differs, plus what it hangs from, so the difference is not
|
|
1562
|
+
* lost among everything that stayed the same (D45). Passing null leaves compare mode.
|
|
1563
|
+
*/
|
|
1564
|
+
setCompare(compare) { view.compare = compare; render(); },
|
|
1565
|
+
setScale(scale, opts = {}) {
|
|
1566
|
+
const changed = view.scale !== scale;
|
|
1567
|
+
view.scale = scale;
|
|
1568
|
+
// Focus and change emphasis belong to their scale; leaving it returns the whole graph (nothing stays faded).
|
|
1569
|
+
view.focusId = scale === 'Work' ? (opts.focusId ?? view.selectionId) : null;
|
|
1570
|
+
render({ refit: changed && !opts.keepView, relayout: changed });
|
|
1571
|
+
},
|
|
1572
|
+
setFilters(filters) { Object.assign(view.filters, filters); render({ relayout: true }); },
|
|
1573
|
+
setShowReplaced(on) { view.showReplaced = on; render({ relayout: true }); },
|
|
1574
|
+
/** D54: `counts` shows how many other links each object has; `lines` draws them all. */
|
|
1575
|
+
setLinks(mode) { view.links = mode === 'lines' ? 'lines' : 'counts'; render(); },
|
|
1576
|
+
expand(id, direction) {
|
|
1577
|
+
const rel = view.data.relations;
|
|
1578
|
+
const ids = direction === 'up' ? upstreamOf(id, rel, new Set()) : downstreamOf(id, rel, new Set());
|
|
1579
|
+
const M = placementOf(view.data, { showReplaced: view.showReplaced });
|
|
1580
|
+
for (const x of ids) { const n = view.data.nodes.find((y) => y.id === x); if (!n) continue; unfold(x, M); if (n.category === 'Work item') view.expandedWork.add(n.id); if (n.category === 'Area') view.expandedGroups.add(`intent:${n.id}`); if (n.parentId) view.expandedWork.add(n.parentId); }
|
|
1581
|
+
render();
|
|
1582
|
+
},
|
|
1583
|
+
reveal(id) {
|
|
1584
|
+
if (!view.data?.nodes.some((x) => x.id === id)) return;
|
|
1585
|
+
unfold(id);
|
|
1586
|
+
render();
|
|
1587
|
+
const el = cy.getElementById(id);
|
|
1588
|
+
if (!el.length) return;
|
|
1589
|
+
// Bring the object to the middle and flash it, so a click in the outline is visible in the graph. The zoom is
|
|
1590
|
+
// left alone: it is the graph's own, which is never below the floor, or the one the owner chose — zoomed far out
|
|
1591
|
+
// to see the whole, the flash shows them where in the whole the object is. An object already in sight is only
|
|
1592
|
+
// flashed: clicking what is on screen does not move the picture under the owner.
|
|
1593
|
+
cy.stop();
|
|
1594
|
+
if (!inView(boxOf(el), windowSize(), cy.zoom(), cy.pan(), 8)) { cy.center(el); ownView(); }
|
|
1595
|
+
el.addClass('hit');
|
|
1596
|
+
const flashed = el.id();
|
|
1597
|
+
setTimeout(() => { if (!pinned.includes(flashed)) cy.getElementById(flashed).removeClass('hit'); }, 1500);
|
|
1598
|
+
},
|
|
1599
|
+
highlight(ids) {
|
|
1600
|
+
pinned = [...ids];
|
|
1601
|
+
cy.elements().removeClass('hit');
|
|
1602
|
+
for (const id of pinned) { cy.getElementById(id).addClass('hit'); copiesOf(id).addClass('hit'); }
|
|
1603
|
+
},
|
|
1604
|
+
/** Drop the marks. The outline's flash is not one of them. */
|
|
1605
|
+
clearMarks() { pinned = []; cy.elements().removeClass('hit'); },
|
|
1606
|
+
/**
|
|
1607
|
+
* Bring objects into view and mark them, without dimming anything else (D48). Folds that hide an object open
|
|
1608
|
+
* first, then the view moves under the same zoom rule as before (not below the floor, not magnified past 1.2),
|
|
1609
|
+
* then the mark is applied. A relation marks its line and both ends. The mark stays until `clearMarks`.
|
|
1610
|
+
* Returns how many of `ids` could be drawn.
|
|
1611
|
+
*/
|
|
1612
|
+
showAffected(ids) {
|
|
1613
|
+
const rels = view.data?.relations ?? [];
|
|
1614
|
+
const nodeIds = [];
|
|
1615
|
+
for (const id of ids) {
|
|
1616
|
+
const rel = rels.find((r) => r.id === id);
|
|
1617
|
+
if (rel) nodeIds.push(rel.from, rel.to);
|
|
1618
|
+
else nodeIds.push(id);
|
|
1619
|
+
}
|
|
1620
|
+
for (const id of nodeIds) unfold(id);
|
|
1621
|
+
render();
|
|
1622
|
+
const els = cy.collection();
|
|
1623
|
+
let found = 0;
|
|
1624
|
+
for (const id of ids) {
|
|
1625
|
+
const rel = rels.find((r) => r.id === id);
|
|
1626
|
+
if (rel) {
|
|
1627
|
+
const edge = cy.getElementById(rel.id);
|
|
1628
|
+
const a = cy.getElementById(rel.from);
|
|
1629
|
+
const b = cy.getElementById(rel.to);
|
|
1630
|
+
if (!edge.length && !a.length && !b.length) continue;
|
|
1631
|
+
found++;
|
|
1632
|
+
if (edge.length) els.merge(edge);
|
|
1633
|
+
if (a.length) els.merge(a);
|
|
1634
|
+
if (b.length) els.merge(b);
|
|
1635
|
+
} else {
|
|
1636
|
+
const el = cy.getElementById(id);
|
|
1637
|
+
if (!el.length) continue;
|
|
1638
|
+
found++;
|
|
1639
|
+
els.merge(el);
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
pinned = [];
|
|
1643
|
+
cy.elements().removeClass('hit');
|
|
1644
|
+
if (els.empty()) return 0;
|
|
1645
|
+
cy.stop();
|
|
1646
|
+
cy.fit(els, 90);
|
|
1647
|
+
// A view the graph chooses: not below the floor (when they are too far apart for that, the middle of them, and
|
|
1648
|
+
// the rest is a drag away), and not blown up either.
|
|
1649
|
+
const z = Math.min(1.2, Math.max(ZOOM_FLOOR, cy.zoom()));
|
|
1650
|
+
if (z !== cy.zoom()) { cy.zoom(z); cy.center(els); }
|
|
1651
|
+
ownView();
|
|
1652
|
+
pinned = els.toArray().map((el) => el.id());
|
|
1653
|
+
els.addClass('hit');
|
|
1654
|
+
return found;
|
|
1655
|
+
},
|
|
1656
|
+
/** The whole picture, as far as its names stay readable; gives the view back to the graph (it follows the window again). */
|
|
1657
|
+
fit() { fitReadable(); },
|
|
1658
|
+
/** A control outside the graph changed what it draws (the process view's `Expand all`, CKC-24): draw again. */
|
|
1659
|
+
refresh() { render(); },
|
|
1660
|
+
/**
|
|
1661
|
+
* The theme changed (Spec §6.16): the palette is read off the page again, the stylesheet takes its colours, and every
|
|
1662
|
+
* object's picture is drawn again in them. Nothing is laid out again and the viewport is not touched — the objects
|
|
1663
|
+
* keep their ids, so the render finds the same picture and only swaps the data that differs.
|
|
1664
|
+
*/
|
|
1665
|
+
repaint() {
|
|
1666
|
+
refreshPalette();
|
|
1667
|
+
cy.style().fromJson(graphStyle(palette)).update();
|
|
1668
|
+
render();
|
|
1669
|
+
},
|
|
1670
|
+
/**
|
|
1671
|
+
* `Show all anyway`: the whole picture in the window whatever that does to the size of its words — the owner's
|
|
1672
|
+
* explicit choice, the one way to see the shape of a large project at a glance. The view is then theirs: a change of
|
|
1673
|
+
* size does not bring the readable fit back, `Fit` does.
|
|
1674
|
+
*/
|
|
1675
|
+
fitAll() {
|
|
1676
|
+
const box = pictureBox(pictureNodes()), size = windowSize();
|
|
1677
|
+
if (!box || size.w < 2 || size.h < 2) return;
|
|
1678
|
+
setZoomLimit(); // cytoscape refuses a view below its minimum zoom; the whole view is never below this limit
|
|
1679
|
+
const v = wholeView(box, size, fitOpts());
|
|
1680
|
+
cy.stop();
|
|
1681
|
+
cy.viewport({ zoom: v.zoom, pan: v.pan });
|
|
1682
|
+
ownView();
|
|
1683
|
+
},
|
|
1684
|
+
labelOf(id) { const el = cy.getElementById(id); return el.length ? (el.data('rawLabel') ?? id) : id; },
|
|
1685
|
+
/**
|
|
1686
|
+
* Where an object is on the page right now, in viewport coordinates: a node's body (not the marks that hang over
|
|
1687
|
+
* its corner), or for a line the spot that was tapped (`at`, in the graph's own coordinates) or its middle. Cut to
|
|
1688
|
+
* the visible canvas — an object panned out of it gives the nearest point of the canvas's edge — and null when the
|
|
1689
|
+
* object is not drawn at all.
|
|
1690
|
+
*/
|
|
1691
|
+
rectOf(id, at = null) {
|
|
1692
|
+
// A work item picked from one of its copies: beside that copy, while it is drawn.
|
|
1693
|
+
const via = anchorAt?.id === id ? cy.getElementById(anchorAt.el) : null;
|
|
1694
|
+
const el = via?.length && via.style('display') !== 'none' ? via : cy.getElementById(id);
|
|
1695
|
+
if (!el.length || el.style('display') === 'none') return null;
|
|
1696
|
+
const box = container.getBoundingClientRect();
|
|
1697
|
+
let r;
|
|
1698
|
+
if (el.isEdge()) {
|
|
1699
|
+
const m = at ?? el.midpoint();
|
|
1700
|
+
if (!m) return null;
|
|
1701
|
+
const x = m.x * cy.zoom() + cy.pan().x, y = m.y * cy.zoom() + cy.pan().y;
|
|
1702
|
+
r = { left: x - 6, top: y - 6, right: x + 6, bottom: y + 6 };
|
|
1703
|
+
} else {
|
|
1704
|
+
const p = el.renderedPosition(), w = el.renderedWidth(), hgt = el.renderedHeight();
|
|
1705
|
+
r = { left: p.x - w / 2, top: p.y - hgt / 2, right: p.x + w / 2, bottom: p.y + hgt / 2 };
|
|
1706
|
+
}
|
|
1707
|
+
const cut = (v, lo, hi) => Math.min(Math.max(v, lo), hi);
|
|
1708
|
+
return { left: box.left + cut(r.left, 0, box.width), top: box.top + cut(r.top, 0, box.height), right: box.left + cut(r.right, 0, box.width), bottom: box.top + cut(r.bottom, 0, box.height) };
|
|
1709
|
+
},
|
|
1710
|
+
destroy() { resizeWatch?.disconnect(); clearTimeout(readTimer); tip.remove(); cy.destroy(); },
|
|
1711
|
+
};
|
|
1712
|
+
}
|