egregore-artifacts 0.5.0 → 0.6.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/lib/parsers/handoff.js +25 -8
- package/lib/shell.js +11 -0
- package/lib/templates/subgraph.js +446 -346
- package/package.json +1 -1
package/lib/parsers/handoff.js
CHANGED
|
@@ -131,7 +131,14 @@ function deriveSessionIdFromPath(filePath) {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
function tryLiveSubgraphQuery(sessionId, gitRoot) {
|
|
134
|
+
// 1-hop neighbors + 2-hop artifact→quest linkage. The artifactQuestLinks
|
|
135
|
+
// collection lets the renderer draw the :PART_OF edges from artifacts up
|
|
136
|
+
// to the quests they belong to — the feature that makes this a graph
|
|
137
|
+
// instead of a list.
|
|
134
138
|
const cypher = `MATCH (s:Session {id: $sessionId})
|
|
139
|
+
OPTIONAL MATCH (s)-[:BY]->(author:Person)
|
|
140
|
+
OPTIONAL MATCH (s)-[:HANDED_TO]->(recipient:Person)
|
|
141
|
+
OPTIONAL MATCH (s)-[:ABOUT]->(project:Project)
|
|
135
142
|
OPTIONAL MATCH (s)-[:IMPLEMENTS]->(prior:Session)
|
|
136
143
|
OPTIONAL MATCH (prior)-[:BY]->(priorAuthor:Person)
|
|
137
144
|
OPTIONAL MATCH (later:Session)-[:IMPLEMENTS]->(s)
|
|
@@ -140,13 +147,18 @@ OPTIONAL MATCH (s)-[:CONTINUES]->(cont:Session)
|
|
|
140
147
|
OPTIONAL MATCH (cont)-[:BY]->(contAuthor:Person)
|
|
141
148
|
OPTIONAL MATCH (s)-[:ADVANCED|INVOLVES]->(quest:Quest)
|
|
142
149
|
OPTIONAL MATCH (s)-[:HAS_ACTIVITY]->(art:Artifact)
|
|
150
|
+
OPTIONAL MATCH (art)-[:PART_OF]->(artQuest:Quest)
|
|
143
151
|
OPTIONAL MATCH (s)-[:PRODUCED]->(pr:PR)
|
|
144
152
|
RETURN
|
|
153
|
+
collect(DISTINCT {name:author.name, github:author.github}) AS authors,
|
|
154
|
+
collect(DISTINCT {name:recipient.name, github:recipient.github}) AS recipients,
|
|
155
|
+
project.name AS project,
|
|
145
156
|
collect(DISTINCT {id:prior.id, topic:prior.topic, author:priorAuthor.name}) AS implementsHandoff,
|
|
146
157
|
collect(DISTINCT {id:later.id, topic:later.topic, author:laterAuthor.name}) AS implementedBy,
|
|
147
158
|
collect(DISTINCT {id:cont.id, topic:cont.topic, author:contAuthor.name}) AS continues,
|
|
148
159
|
collect(DISTINCT {id:quest.id, title:quest.title}) AS quests,
|
|
149
|
-
collect(DISTINCT {id:art.id, title:art.title, type:art.type
|
|
160
|
+
collect(DISTINCT {id:art.id, title:art.title, type:art.type}) AS artifacts,
|
|
161
|
+
collect(DISTINCT {artifactId:art.id, questId:artQuest.id, questTitle:artQuest.title}) AS artifactQuestLinks,
|
|
150
162
|
collect(DISTINCT {id:pr.id, number:pr.number, title:pr.title, repo:pr.repo}) AS prs`;
|
|
151
163
|
|
|
152
164
|
try {
|
|
@@ -177,17 +189,22 @@ function parseGraphResponse(raw) {
|
|
|
177
189
|
const obj = {};
|
|
178
190
|
fields.forEach((f, i) => { obj[f] = row[i]; });
|
|
179
191
|
|
|
180
|
-
// OPTIONAL MATCH + collect() emits sentinel
|
|
192
|
+
// OPTIONAL MATCH + collect() emits sentinel entries when nothing matched —
|
|
193
|
+
// filter them out by requiring at least one of the key fields to be set.
|
|
181
194
|
const clean = (list, ...keys) => Array.isArray(list)
|
|
182
195
|
? list.filter(x => x && keys.some(k => x[k] != null))
|
|
183
196
|
: [];
|
|
184
197
|
return {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
198
|
+
authors: clean(obj.authors, 'name', 'github'),
|
|
199
|
+
recipients: clean(obj.recipients, 'name', 'github'),
|
|
200
|
+
project: obj.project || null,
|
|
201
|
+
implementsHandoff: clean(obj.implementsHandoff, 'id'),
|
|
202
|
+
implementedBy: clean(obj.implementedBy, 'id'),
|
|
203
|
+
continues: clean(obj.continues, 'id'),
|
|
204
|
+
quests: clean(obj.quests, 'id', 'title'),
|
|
205
|
+
artifacts: clean(obj.artifacts, 'id', 'title'),
|
|
206
|
+
artifactQuestLinks: clean(obj.artifactQuestLinks, 'artifactId', 'questId'),
|
|
207
|
+
prs: clean(obj.prs, 'id', 'number'),
|
|
191
208
|
};
|
|
192
209
|
}
|
|
193
210
|
|
package/lib/shell.js
CHANGED
|
@@ -45,6 +45,13 @@ export function htmlShell(bodyHtml, { title = 'Egregore Artifact', type = 'artif
|
|
|
45
45
|
--success-bg: #e8f5e9;
|
|
46
46
|
--success-fg: #2e7d32;
|
|
47
47
|
--green-p1: #2A7B5B;
|
|
48
|
+
/* Subgraph node fills — subtle tinted backgrounds per node type.
|
|
49
|
+
Strokes/labels use the existing strong tokens (--terracotta,
|
|
50
|
+
--blue-muted, --green-p1, --gold, etc.) for contrast. */
|
|
51
|
+
--quest-fill: rgba(42, 123, 91, 0.12);
|
|
52
|
+
--artifact-fill: rgba(176, 141, 87, 0.14);
|
|
53
|
+
--artifact-stroke: #B08D57;
|
|
54
|
+
--pr-fill: rgba(59, 45, 33, 0.04);
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
/* Dark mode overrides */
|
|
@@ -67,6 +74,10 @@ export function htmlShell(bodyHtml, { title = 'Egregore Artifact', type = 'artif
|
|
|
67
74
|
--success-bg: rgba(107, 191, 107, 0.12);
|
|
68
75
|
--success-fg: #9ed9a0;
|
|
69
76
|
--green-p1: #6BBF6B;
|
|
77
|
+
--quest-fill: rgba(107, 191, 107, 0.14);
|
|
78
|
+
--artifact-fill: rgba(200, 169, 122, 0.16);
|
|
79
|
+
--artifact-stroke: #C8A97A;
|
|
80
|
+
--pr-fill: rgba(255, 255, 255, 0.04);
|
|
70
81
|
}
|
|
71
82
|
|
|
72
83
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
@@ -1,297 +1,376 @@
|
|
|
1
1
|
// Handoff subgraph → inline static SVG
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// Renders the session's knowledge-graph neighborhood AS A GRAPH — typed
|
|
4
|
+
// nodes with labeled edges — rather than as a flat list or zones. Gives
|
|
5
|
+
// the recipient a visual orientation: where this sits, who/what it's
|
|
6
|
+
// connected to, which quests it's part of, what it produced.
|
|
5
7
|
//
|
|
6
|
-
//
|
|
7
|
-
// artifact column without scaling down and losing legibility:
|
|
8
|
+
// Layered DAG layout (top-to-bottom):
|
|
8
9
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
10
|
+
// Row 0 (optional) [prior session]
|
|
11
|
+
// │ IMPLEMENTS / CONTINUES
|
|
12
|
+
// Row 1 [ THIS HANDOFF ]
|
|
13
|
+
// ╱ BY │ABOUT ╲ ADVANCED
|
|
14
|
+
// Row 2 [author, recipients] [project] [quests ...]
|
|
15
|
+
// │ ▲
|
|
16
|
+
// │ HAS_ACTIVITY ╭─ PART_OF ──╯
|
|
17
|
+
// ▼ │
|
|
18
|
+
// Row 3 [artifacts ...] [PRs ...] [later sessions]
|
|
19
|
+
// │ PRODUCED ▲
|
|
20
|
+
// │ IMPLEMENTS (incoming)
|
|
19
21
|
//
|
|
20
|
-
//
|
|
21
|
-
// [PR #603 · egregore] [harvest · …] [decision · …]
|
|
22
|
+
// Legend ● Session ● Person ● Project ● Quest ● Artifact ● PR
|
|
22
23
|
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
24
|
+
// Rows collapse when empty. If the session has no graph-only context
|
|
25
|
+
// (subgraph is null or has only header-duplicating data), the whole
|
|
26
|
+
// section is skipped.
|
|
25
27
|
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
// Zero runtime JS. Colors emitted as var(--token) so dark-mode reaches them.
|
|
28
|
+
// Zero runtime JS — pure SSR SVG. All colors via var(--token) so dark
|
|
29
|
+
// mode reaches them.
|
|
30
30
|
import React from 'react';
|
|
31
31
|
import { fonts } from '../tokens.js';
|
|
32
32
|
|
|
33
33
|
const h = React.createElement;
|
|
34
34
|
|
|
35
|
+
// ── Text sizing heuristics ────────────────────────────────────────
|
|
35
36
|
const CHAR_W_SANS_12 = 7.0;
|
|
36
37
|
const CHAR_W_SANS_13 = 7.6;
|
|
37
|
-
const CHAR_W_MONO_10 = 6.0;
|
|
38
38
|
const CHAR_W_MONO_9 = 5.4;
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
40
|
+
// ── Node geometry ──────────────────────────────────────────────────
|
|
41
|
+
const PILL_H = 32;
|
|
42
|
+
const PILL_PAD_X = 22;
|
|
43
|
+
const PILL_MIN_W = 110;
|
|
44
|
+
const PILL_MAX_W = 260;
|
|
45
|
+
const SESSION_H = 56;
|
|
46
|
+
const SESSION_MIN_W = 220;
|
|
47
|
+
const SESSION_MAX_W = 360;
|
|
48
|
+
|
|
49
|
+
// ── Row y-coordinates ──────────────────────────────────────────────
|
|
50
|
+
const ROW_H = 90;
|
|
51
|
+
const TOP_PAD = 30;
|
|
52
|
+
const SIDE_PAD = 18;
|
|
53
|
+
const LEGEND_H = 34;
|
|
54
|
+
|
|
55
|
+
// ── Truncation caps ────────────────────────────────────────────────
|
|
56
|
+
const TRUNC_SESSION = 60;
|
|
57
|
+
const TRUNC_PILL = 26;
|
|
58
|
+
const TRUNC_ARTIFACT = 28;
|
|
59
|
+
|
|
60
|
+
// ── Count caps (to keep the SVG within the container width) ────────
|
|
61
|
+
const MAX_AUTHORS = 2;
|
|
62
|
+
const MAX_RECIPIENTS = 2;
|
|
63
|
+
const MAX_QUESTS = 3;
|
|
64
|
+
const MAX_ARTIFACTS = 4;
|
|
65
|
+
const MAX_PRS = 3;
|
|
66
|
+
const MAX_LATER = 2;
|
|
67
|
+
|
|
68
|
+
// ── Colors (CSS vars) ──────────────────────────────────────────────
|
|
69
|
+
const STYLE = {
|
|
70
|
+
session: { fill: 'var(--terracotta-chip)', stroke: 'var(--terracotta)', text: 'var(--black)' },
|
|
71
|
+
person: { fill: 'var(--blue-chip)', stroke: 'var(--blue-muted)', text: 'var(--blue-muted)' },
|
|
72
|
+
project: { fill: 'var(--subtle-fill)', stroke: 'var(--border)', text: 'var(--dark)' },
|
|
73
|
+
quest: { fill: 'var(--quest-fill)', stroke: 'var(--green-p1)', text: 'var(--green-p1)' },
|
|
74
|
+
artifact: { fill: 'var(--artifact-fill)', stroke: 'var(--artifact-stroke)', text: 'var(--artifact-stroke)' },
|
|
75
|
+
pr: { fill: 'var(--pr-fill)', stroke: 'var(--border)', text: 'var(--dark)' },
|
|
76
|
+
};
|
|
57
77
|
|
|
58
78
|
export function renderSubgraph({ subgraph, handoffTopic }) {
|
|
59
79
|
if (!subgraph) return null;
|
|
60
80
|
|
|
61
81
|
const {
|
|
62
|
-
|
|
63
|
-
implementsHandoff = [],
|
|
64
|
-
|
|
65
|
-
quests = [],
|
|
66
|
-
artifacts = [],
|
|
67
|
-
prs = [],
|
|
82
|
+
authors = [], recipients = [], project = null,
|
|
83
|
+
continues = [], implementsHandoff = [], implementedBy = [],
|
|
84
|
+
quests = [], artifacts = [], artifactQuestLinks = [], prs = [],
|
|
68
85
|
} = subgraph;
|
|
69
86
|
|
|
87
|
+
// Prior = the most-specific upstream node (implements > continues, pick first).
|
|
70
88
|
const priorNode = implementsHandoff[0] || continues[0] || null;
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
89
|
+
const priorRel = implementsHandoff[0] ? 'IMPLEMENTS' : (continues[0] ? 'CONTINUES' : null);
|
|
90
|
+
|
|
91
|
+
const authorList = authors.slice(0, MAX_AUTHORS);
|
|
92
|
+
const recipientList = recipients.slice(0, MAX_RECIPIENTS);
|
|
93
|
+
const questList = quests.slice(0, MAX_QUESTS);
|
|
94
|
+
const artifactList = artifacts.slice(0, MAX_ARTIFACTS);
|
|
95
|
+
const prList = prs.slice(0, MAX_PRS);
|
|
96
|
+
const laterList = implementedBy.slice(0, MAX_LATER);
|
|
97
|
+
const overflowQuests = quests.length - questList.length;
|
|
98
|
+
const overflowArtifacts = artifacts.length - artifactList.length;
|
|
99
|
+
const overflowPrs = prs.length - prList.length;
|
|
100
|
+
|
|
101
|
+
// Gate: we render the section if there's any *graph-only* context —
|
|
102
|
+
// things not already in the header. Author/recipients/project alone
|
|
103
|
+
// aren't enough (they duplicate the metadata row).
|
|
104
|
+
const hasGraphOnlyContext =
|
|
105
|
+
priorNode || laterList.length || questList.length ||
|
|
106
|
+
artifactList.length || prList.length;
|
|
107
|
+
if (!hasGraphOnlyContext) return null;
|
|
108
|
+
|
|
109
|
+
// ── Assign nodes to layout slots ──────────────────────────────────
|
|
110
|
+
// Each node = { id, kind, label, x, y, w }. We compute widths first
|
|
111
|
+
// from label text, then pack rows, then assign y per row.
|
|
112
|
+
|
|
113
|
+
const mkNode = (id, kind, label, sub = null) => ({
|
|
114
|
+
id, kind, label, sub, w: pillWidth(label, kind === 'session'),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const nodeSession = mkNode('session', 'session', truncate(handoffTopic || 'this handoff', TRUNC_SESSION));
|
|
118
|
+
|
|
119
|
+
const r1Left = authorList.map((a, i) => mkNode(`author-${i}`, 'person', truncate(a.name || a.github || 'author', TRUNC_PILL)))
|
|
120
|
+
.concat(recipientList.map((r, i) => mkNode(`recip-${i}`, 'person', truncate(r.name || r.github || 'recipient', TRUNC_PILL))));
|
|
121
|
+
const r1Center = project ? [mkNode('project', 'project', truncate(project, TRUNC_PILL))] : [];
|
|
122
|
+
const r1Right = questList.map((q, i) => mkNode(`quest-${q.id || i}`, 'quest', truncate(q.title || q.id || 'quest', TRUNC_PILL)));
|
|
123
|
+
|
|
124
|
+
const r2Left = artifactList.map((a, i) => mkNode(
|
|
125
|
+
`art-${a.id || i}`, 'artifact',
|
|
126
|
+
truncate(a.title || a.id || 'artifact', TRUNC_ARTIFACT),
|
|
127
|
+
a.type || null,
|
|
128
|
+
));
|
|
129
|
+
const r2Right = prList.map((p, i) => mkNode(
|
|
130
|
+
`pr-${p.id || p.number || i}`, 'pr',
|
|
131
|
+
`PR #${p.number || p.id}${p.repo ? ' · ' + p.repo : ''}`,
|
|
132
|
+
)).concat(laterList.map((l, i) => mkNode(
|
|
133
|
+
`later-${i}`, 'session',
|
|
134
|
+
truncate(l.topic || l.id || 'session', TRUNC_PILL),
|
|
135
|
+
)));
|
|
136
|
+
|
|
137
|
+
// Row 1 (identity): Persons on the left, Project in the center.
|
|
138
|
+
// Row 2 (quests): on its own row so longer quest titles don't push widths.
|
|
139
|
+
// Row 3 (outputs): Artifacts on the left, PRs + later sessions on the right.
|
|
140
|
+
const hasRow0 = Boolean(priorNode);
|
|
141
|
+
const hasRow1 = r1Left.length || r1Center.length; // persons + project only
|
|
142
|
+
const hasQuestsRow = r1Right.length > 0; // quests
|
|
143
|
+
const hasRow2 = r2Left.length || r2Right.length;
|
|
144
|
+
|
|
145
|
+
// ── Compute canvas width from widest row ─────────────────────────
|
|
146
|
+
const GAP = 14;
|
|
147
|
+
const widthOfRow = (items, extra = 0) =>
|
|
148
|
+
items.reduce((a, n) => a + n.w, 0) + Math.max(0, items.length - 1) * GAP + extra;
|
|
149
|
+
|
|
150
|
+
const r1W = widthOfRow(r1Left, r1Center.length ? 32 : 0) + widthOfRow(r1Center);
|
|
151
|
+
const questsW = widthOfRow(r1Right);
|
|
152
|
+
const r2W = widthOfRow(r2Left, r2Right.length ? 32 : 0) + widthOfRow(r2Right);
|
|
153
|
+
const priorW = priorNode ? pillWidth(truncate(priorNode.topic || priorNode.id || '', TRUNC_PILL)) : 0;
|
|
154
|
+
const sessionW = nodeSession.w;
|
|
155
|
+
|
|
156
|
+
const contentW = Math.max(r1W, questsW, r2W, priorW, sessionW, 460);
|
|
157
|
+
const WIDTH = contentW + SIDE_PAD * 2;
|
|
158
|
+
const CX = WIDTH / 2;
|
|
159
|
+
|
|
160
|
+
// ── Assign y coordinates ─────────────────────────────────────────
|
|
161
|
+
let cy = TOP_PAD;
|
|
162
|
+
let yPrior = null, ySession = null, yRow1 = null, yQuests = null, yRow2 = null;
|
|
163
|
+
|
|
164
|
+
if (hasRow0) {
|
|
165
|
+
yPrior = cy + PILL_H / 2;
|
|
166
|
+
cy += ROW_H;
|
|
167
|
+
}
|
|
168
|
+
ySession = cy + SESSION_H / 2;
|
|
169
|
+
cy += SESSION_H + 34;
|
|
107
170
|
|
|
108
|
-
|
|
109
|
-
|
|
171
|
+
if (hasRow1) {
|
|
172
|
+
yRow1 = cy + PILL_H / 2;
|
|
173
|
+
cy += ROW_H - 6;
|
|
174
|
+
}
|
|
175
|
+
if (hasQuestsRow) {
|
|
176
|
+
yQuests = cy + PILL_H / 2;
|
|
177
|
+
cy += ROW_H - 6;
|
|
178
|
+
}
|
|
179
|
+
if (hasRow2) {
|
|
180
|
+
yRow2 = cy + PILL_H / 2;
|
|
181
|
+
cy += ROW_H - 6;
|
|
182
|
+
}
|
|
183
|
+
const HEIGHT = cy + LEGEND_H + 8;
|
|
184
|
+
|
|
185
|
+
// ── Lay out nodes horizontally per row ───────────────────────────
|
|
186
|
+
let priorPlaced = null;
|
|
187
|
+
if (hasRow0) {
|
|
188
|
+
priorPlaced = mkNode('prior', 'session',
|
|
189
|
+
truncate(priorNode.topic || priorNode.id || 'prior session', TRUNC_PILL),
|
|
190
|
+
priorNode.author ? `by ${priorNode.author}` : null);
|
|
191
|
+
priorPlaced.x = CX - priorPlaced.w / 2;
|
|
192
|
+
priorPlaced.y = yPrior - PILL_H / 2;
|
|
193
|
+
priorPlaced.h = PILL_H;
|
|
194
|
+
}
|
|
110
195
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
196
|
+
nodeSession.x = CX - nodeSession.w / 2;
|
|
197
|
+
nodeSession.y = ySession - SESSION_H / 2;
|
|
198
|
+
nodeSession.h = SESSION_H;
|
|
199
|
+
|
|
200
|
+
// Row 1 (persons + project): persons left, project centered-right.
|
|
201
|
+
if (hasRow1) layoutRow(r1Left, r1Center, [], yRow1, WIDTH);
|
|
202
|
+
// Quests row: center the whole group.
|
|
203
|
+
if (hasQuestsRow) {
|
|
204
|
+
const total = questsW;
|
|
205
|
+
const startX = Math.max(SIDE_PAD, (WIDTH - total) / 2);
|
|
206
|
+
placeHorizontally(r1Right, startX, yQuests - PILL_H / 2, GAP);
|
|
207
|
+
}
|
|
208
|
+
// Outputs row: artifacts left, PRs + later right.
|
|
209
|
+
if (hasRow2) layoutRow(r2Left, [], r2Right, yRow2, WIDTH);
|
|
124
210
|
|
|
125
|
-
//
|
|
126
|
-
const
|
|
127
|
-
const questsMaxRowW = questRows.reduce((m, r) => Math.max(m, rowWidth(r)), 0)
|
|
128
|
-
+ (questOverflow > 0 ? 80 : 0);
|
|
129
|
-
const outputsMaxRowW = outputRows.reduce((m, r) => Math.max(m, rowWidth(r)), 0)
|
|
130
|
-
+ (outputsOverflow > 0 ? 80 : 0);
|
|
211
|
+
// ── Collect SVG children ─────────────────────────────────────────
|
|
212
|
+
const children = [];
|
|
131
213
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
214
|
+
// Arrow marker
|
|
215
|
+
children.push(h('defs', { key: 'defs' },
|
|
216
|
+
h('marker', {
|
|
217
|
+
id: 'sg-arrow',
|
|
218
|
+
viewBox: '0 0 10 10',
|
|
219
|
+
refX: 9, refY: 5,
|
|
220
|
+
markerWidth: 6, markerHeight: 6,
|
|
221
|
+
orient: 'auto-start-reverse',
|
|
222
|
+
},
|
|
223
|
+
h('path', { d: 'M0,0 L10,5 L0,10 z', fill: 'var(--border)' })
|
|
224
|
+
),
|
|
225
|
+
));
|
|
135
226
|
|
|
136
|
-
// ──
|
|
137
|
-
const children = [];
|
|
138
|
-
let cy = 14;
|
|
139
|
-
|
|
140
|
-
// Zone: Lineage (vertical spine)
|
|
141
|
-
if (hasLineage) {
|
|
142
|
-
if (priorNode) {
|
|
143
|
-
const y = cy;
|
|
144
|
-
const x = CX - priorW / 2;
|
|
145
|
-
children.push(pill({
|
|
146
|
-
key: 'prior', x, y, width: priorW,
|
|
147
|
-
label: priorText, tone: 'neutral',
|
|
148
|
-
}));
|
|
149
|
-
if (priorNode.author) {
|
|
150
|
-
children.push(subLabel({
|
|
151
|
-
key: 'prior-sub', x: CX, y: y + PILL_H + 12,
|
|
152
|
-
text: `by ${priorNode.author}`,
|
|
153
|
-
}));
|
|
154
|
-
}
|
|
155
|
-
cy += PILL_H + SUB_LINE_H;
|
|
156
|
-
|
|
157
|
-
// Edge from prior to handoff (arrow pointing down, out of prior toward this)
|
|
158
|
-
const e1 = cy + 2;
|
|
159
|
-
const e2 = cy + VERT_EDGE - 6;
|
|
160
|
-
children.push(h('line', {
|
|
161
|
-
key: 'prior-edge',
|
|
162
|
-
x1: CX, y1: e1, x2: CX, y2: e2,
|
|
163
|
-
stroke: 'var(--border)', strokeWidth: 1.5,
|
|
164
|
-
markerEnd: 'url(#sg-arrow)',
|
|
165
|
-
}));
|
|
166
|
-
const lbl = `${priorLabel}${priorMore > 0 ? ` · +${priorMore}` : ''}`;
|
|
167
|
-
children.push(edgeLabel({
|
|
168
|
-
key: 'prior-edge-lbl',
|
|
169
|
-
x: CX + 10, y: (e1 + e2) / 2 + 3,
|
|
170
|
-
text: lbl, anchor: 'start',
|
|
171
|
-
}));
|
|
172
|
-
cy += VERT_EDGE;
|
|
173
|
-
}
|
|
227
|
+
// ── Edges (drawn first so they sit behind nodes) ─────────────────
|
|
174
228
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
fill: 'var(--terracotta-chip)',
|
|
183
|
-
stroke: 'var(--terracotta)', strokeWidth: 1.5,
|
|
229
|
+
// Row 0 → session (IMPLEMENTS or CONTINUES)
|
|
230
|
+
if (hasRow0 && priorPlaced) {
|
|
231
|
+
children.push(edge({
|
|
232
|
+
key: 'e-prior',
|
|
233
|
+
x1: CX, y1: priorPlaced.y + PILL_H,
|
|
234
|
+
x2: CX, y2: nodeSession.y,
|
|
235
|
+
label: priorRel,
|
|
184
236
|
}));
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
key: 'later-edge',
|
|
207
|
-
x1: CX, y1: e1, x2: CX, y2: e2,
|
|
208
|
-
stroke: 'var(--border)', strokeWidth: 1.5,
|
|
209
|
-
markerEnd: 'url(#sg-arrow)',
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Session → Row 1 nodes
|
|
240
|
+
if (hasRow1) {
|
|
241
|
+
r1Left.forEach((n, i) => {
|
|
242
|
+
const rel = n.id.startsWith('author-') ? 'BY' : 'HANDED_TO';
|
|
243
|
+
children.push(edge({
|
|
244
|
+
key: `e-s-${n.id}`,
|
|
245
|
+
x1: nodeSession.x + sessionW * (0.35 - 0.1 * Math.min(i, 2)),
|
|
246
|
+
y1: nodeSession.y + SESSION_H,
|
|
247
|
+
x2: n.x + n.w / 2,
|
|
248
|
+
y2: n.y,
|
|
249
|
+
label: rel,
|
|
250
|
+
}));
|
|
251
|
+
});
|
|
252
|
+
r1Center.forEach((n) => {
|
|
253
|
+
children.push(edge({
|
|
254
|
+
key: `e-s-${n.id}`,
|
|
255
|
+
x1: CX, y1: nodeSession.y + SESSION_H,
|
|
256
|
+
x2: n.x + n.w / 2, y2: n.y,
|
|
257
|
+
label: 'ABOUT',
|
|
210
258
|
}));
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
if (hasQuestsRow) {
|
|
262
|
+
// Quests live on their own row (below persons+project), so these edges
|
|
263
|
+
// need to curve around the Row 1 nodes rather than crossing them.
|
|
264
|
+
r1Right.forEach((n, i) => {
|
|
265
|
+
const sx = nodeSession.x + sessionW * (0.4 + 0.1 * i);
|
|
266
|
+
children.push(edge({
|
|
267
|
+
key: `e-s-${n.id}`,
|
|
268
|
+
x1: sx,
|
|
269
|
+
y1: nodeSession.y + SESSION_H,
|
|
270
|
+
x2: n.x + n.w / 2,
|
|
271
|
+
y2: n.y,
|
|
272
|
+
label: i === 0 ? 'ADVANCED' : null,
|
|
273
|
+
curved: true,
|
|
216
274
|
}));
|
|
217
|
-
|
|
275
|
+
});
|
|
276
|
+
}
|
|
218
277
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
278
|
+
// Session → Row 2 (artifacts and PRs), Later → Session
|
|
279
|
+
if (hasRow2) {
|
|
280
|
+
r2Left.forEach((n, i) => {
|
|
281
|
+
children.push(edge({
|
|
282
|
+
key: `e-s-${n.id}`,
|
|
283
|
+
x1: nodeSession.x + sessionW * 0.3,
|
|
284
|
+
y1: nodeSession.y + SESSION_H,
|
|
285
|
+
x2: n.x + n.w / 2, y2: n.y,
|
|
286
|
+
label: i === 0 ? 'HAS_ACTIVITY' : null,
|
|
287
|
+
curved: true,
|
|
224
288
|
}));
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
289
|
+
});
|
|
290
|
+
r2Right.forEach((n, i) => {
|
|
291
|
+
if (n.id.startsWith('later-')) {
|
|
292
|
+
// incoming IMPLEMENTS
|
|
293
|
+
children.push(edge({
|
|
294
|
+
key: `e-${n.id}-s`,
|
|
295
|
+
x1: n.x + n.w / 2, y1: n.y,
|
|
296
|
+
x2: nodeSession.x + sessionW * 0.7, y2: nodeSession.y + SESSION_H,
|
|
297
|
+
label: i === 0 ? 'IMPLEMENTS' : null,
|
|
298
|
+
curved: true,
|
|
299
|
+
}));
|
|
300
|
+
} else {
|
|
301
|
+
children.push(edge({
|
|
302
|
+
key: `e-s-${n.id}`,
|
|
303
|
+
x1: nodeSession.x + sessionW * 0.7,
|
|
304
|
+
y1: nodeSession.y + SESSION_H,
|
|
305
|
+
x2: n.x + n.w / 2, y2: n.y,
|
|
306
|
+
label: n.id.startsWith('pr-') ? (i === 0 ? 'PRODUCED' : null) : null,
|
|
307
|
+
curved: true,
|
|
229
308
|
}));
|
|
230
309
|
}
|
|
231
|
-
|
|
232
|
-
|
|
310
|
+
});
|
|
311
|
+
}
|
|
233
312
|
|
|
234
|
-
|
|
313
|
+
// 2-hop: artifact → quest (PART_OF). Curved lines routed up from Row 2
|
|
314
|
+
// artifacts to their Row 1 quests.
|
|
315
|
+
if (hasRow1 && hasRow2 && artifactQuestLinks.length > 0) {
|
|
316
|
+
const questNodeById = new Map(r1Right.map(n => [n.id.replace(/^quest-/, ''), n]));
|
|
317
|
+
const artNodeById = new Map(r2Left.map(n => [n.id.replace(/^art-/, ''), n]));
|
|
318
|
+
let edgeIdx = 0;
|
|
319
|
+
for (const link of artifactQuestLinks) {
|
|
320
|
+
const artNode = artNodeById.get(link.artifactId);
|
|
321
|
+
const questNode = questNodeById.get(link.questId);
|
|
322
|
+
if (!artNode || !questNode) continue;
|
|
323
|
+
children.push(edge({
|
|
324
|
+
key: `e-a-q-${edgeIdx++}`,
|
|
325
|
+
x1: artNode.x + artNode.w / 2, y1: artNode.y,
|
|
326
|
+
x2: questNode.x + questNode.w / 2, y2: questNode.y + PILL_H,
|
|
327
|
+
label: edgeIdx === 1 ? 'PART_OF' : null,
|
|
328
|
+
curved: true,
|
|
329
|
+
dashed: true,
|
|
330
|
+
}));
|
|
331
|
+
}
|
|
235
332
|
}
|
|
236
333
|
|
|
237
|
-
//
|
|
238
|
-
if (
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
fill: 'var(--muted)',
|
|
259
|
-
}, `+${outputsOverflow} more`));
|
|
260
|
-
}
|
|
261
|
-
cy += CHIP_H + 8;
|
|
262
|
-
});
|
|
263
|
-
cy += ZONE_GAP;
|
|
334
|
+
// ── Nodes ────────────────────────────────────────────────────────
|
|
335
|
+
if (hasRow0 && priorPlaced) children.push(renderNode({ ...priorPlaced, key: 'n-prior' }));
|
|
336
|
+
children.push(renderSessionNode({ ...nodeSession, key: 'n-session' }));
|
|
337
|
+
if (hasRow1) [...r1Left, ...r1Center].forEach((n) => children.push(renderNode({ ...n, key: `n-${n.id}` })));
|
|
338
|
+
if (hasQuestsRow) r1Right.forEach((n) => children.push(renderNode({ ...n, key: `n-${n.id}` })));
|
|
339
|
+
if (hasRow2) [...r2Left, ...r2Right].forEach((n) => children.push(renderNode({ ...n, key: `n-${n.id}` })));
|
|
340
|
+
|
|
341
|
+
// ── Overflow indicators ──────────────────────────────────────────
|
|
342
|
+
if (overflowQuests > 0 && r1Right.length > 0) {
|
|
343
|
+
const last = r1Right[r1Right.length - 1];
|
|
344
|
+
children.push(overflowLabel({ key: 'of-q', x: last.x + last.w + 10, y: last.y + PILL_H / 2 + 3, n: overflowQuests }));
|
|
345
|
+
}
|
|
346
|
+
if (overflowArtifacts > 0 && r2Left.length > 0) {
|
|
347
|
+
const last = r2Left[r2Left.length - 1];
|
|
348
|
+
children.push(overflowLabel({ key: 'of-a', x: last.x + last.w / 2, y: last.y + PILL_H + 24, n: overflowArtifacts, anchor: 'middle' }));
|
|
349
|
+
}
|
|
350
|
+
if (overflowPrs > 0 && r2Right.length > 0) {
|
|
351
|
+
const lastPr = r2Right.filter(n => n.id.startsWith('pr-')).pop();
|
|
352
|
+
if (lastPr) {
|
|
353
|
+
children.push(overflowLabel({ key: 'of-p', x: lastPr.x + lastPr.w / 2, y: lastPr.y + PILL_H + 24, n: overflowPrs, anchor: 'middle' }));
|
|
354
|
+
}
|
|
264
355
|
}
|
|
265
356
|
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
children.push(chip({
|
|
277
|
-
key: `q-${rIdx}-${i}`, x: ox, y: cy + CHIP_H,
|
|
278
|
-
width: c.w, label: c.label, tone: c.tone,
|
|
279
|
-
}));
|
|
280
|
-
ox += c.w + 8;
|
|
281
|
-
});
|
|
282
|
-
if (rIdx === rows.length - 1 && questOverflow > 0) {
|
|
283
|
-
children.push(h('text', {
|
|
284
|
-
key: 'q-more',
|
|
285
|
-
x: ox + 6, y: cy + CHIP_H - 6,
|
|
357
|
+
// ── Legend ───────────────────────────────────────────────────────
|
|
358
|
+
const legendTypes = ['session', 'person', 'project', 'quest', 'artifact', 'pr'];
|
|
359
|
+
const legendLabels = { session: 'Session', person: 'Person', project: 'Project', quest: 'Quest', artifact: 'Artifact', pr: 'PR' };
|
|
360
|
+
children.push(h('g', { key: 'legend', transform: `translate(${SIDE_PAD}, ${HEIGHT - LEGEND_H + 4})` },
|
|
361
|
+
...legendTypes.map((t, i) => {
|
|
362
|
+
const x = i * 88;
|
|
363
|
+
return h('g', { key: `lg-${t}` },
|
|
364
|
+
h('circle', { cx: x + 6, cy: 10, r: 5, fill: STYLE[t].stroke, fillOpacity: 0.9 }),
|
|
365
|
+
h('text', {
|
|
366
|
+
x: x + 17, y: 14,
|
|
286
367
|
fontFamily: fonts.mono, fontSize: 10,
|
|
287
368
|
fill: 'var(--muted)',
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
})
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const VIEW_H = cy + 10;
|
|
369
|
+
letterSpacing: '0.03em',
|
|
370
|
+
}, legendLabels[t]),
|
|
371
|
+
);
|
|
372
|
+
}),
|
|
373
|
+
));
|
|
295
374
|
|
|
296
375
|
return h('div', {
|
|
297
376
|
style: {
|
|
@@ -302,151 +381,172 @@ export function renderSubgraph({ subgraph, handoffTopic }) {
|
|
|
302
381
|
},
|
|
303
382
|
h('div', {
|
|
304
383
|
style: {
|
|
305
|
-
fontFamily: fonts.mono,
|
|
306
|
-
|
|
307
|
-
textTransform: 'uppercase',
|
|
308
|
-
letterSpacing: '0.08em',
|
|
384
|
+
fontFamily: fonts.mono, fontSize: '11px',
|
|
385
|
+
textTransform: 'uppercase', letterSpacing: '0.08em',
|
|
309
386
|
color: 'var(--muted)',
|
|
310
387
|
marginBottom: '0.75rem',
|
|
311
388
|
paddingLeft: '0.25rem',
|
|
312
389
|
},
|
|
313
390
|
}, 'Subgraph'),
|
|
314
391
|
h('svg', {
|
|
315
|
-
viewBox: `0 0 ${
|
|
392
|
+
viewBox: `0 0 ${WIDTH} ${HEIGHT}`,
|
|
316
393
|
width: '100%',
|
|
317
394
|
height: 'auto',
|
|
318
395
|
preserveAspectRatio: 'xMidYMid meet',
|
|
319
|
-
style: { display: 'block', maxHeight: '
|
|
396
|
+
style: { display: 'block', maxHeight: '640px' },
|
|
320
397
|
role: 'img',
|
|
321
398
|
'aria-label': 'Handoff knowledge-graph neighborhood',
|
|
322
|
-
},
|
|
323
|
-
h('defs', null,
|
|
324
|
-
h('marker', {
|
|
325
|
-
id: 'sg-arrow',
|
|
326
|
-
viewBox: '0 0 10 10',
|
|
327
|
-
refX: 9, refY: 5,
|
|
328
|
-
markerWidth: 7, markerHeight: 7,
|
|
329
|
-
orient: 'auto-start-reverse',
|
|
330
|
-
},
|
|
331
|
-
h('path', { d: 'M0,0 L10,5 L0,10 z', fill: 'var(--border)' })
|
|
332
|
-
),
|
|
333
|
-
),
|
|
334
|
-
...children,
|
|
335
|
-
),
|
|
399
|
+
}, ...children),
|
|
336
400
|
);
|
|
337
401
|
}
|
|
338
402
|
|
|
339
|
-
// ── helpers
|
|
403
|
+
// ── layout helpers ──────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
function layoutRow(leftNodes, centerNodes, rightNodes, yCenter, width) {
|
|
406
|
+
const GAP = 14;
|
|
407
|
+
const leftW = rowSpan(leftNodes, GAP);
|
|
408
|
+
const centerW = rowSpan(centerNodes, GAP);
|
|
409
|
+
const rightW = rowSpan(rightNodes, GAP);
|
|
410
|
+
const total = leftW + centerW + rightW;
|
|
411
|
+
|
|
412
|
+
// If three zones fit comfortably, space them; otherwise compress.
|
|
413
|
+
const available = width - SIDE_PAD * 2;
|
|
414
|
+
const slack = Math.max(0, available - total);
|
|
415
|
+
const padBetween = centerNodes.length > 0 ? slack / 2 : slack;
|
|
416
|
+
|
|
417
|
+
let cursor = SIDE_PAD;
|
|
418
|
+
placeHorizontally(leftNodes, cursor, yCenter - PILL_H / 2, GAP);
|
|
419
|
+
cursor = SIDE_PAD + leftW + (leftNodes.length ? padBetween : 0);
|
|
420
|
+
if (centerNodes.length) {
|
|
421
|
+
// Center the center zone relative to the canvas
|
|
422
|
+
const centerStart = (width - centerW) / 2;
|
|
423
|
+
placeHorizontally(centerNodes, centerStart, yCenter - PILL_H / 2, GAP);
|
|
424
|
+
cursor = centerStart + centerW + padBetween;
|
|
425
|
+
}
|
|
426
|
+
// Right zone flush to right
|
|
427
|
+
const rightStart = width - SIDE_PAD - rightW;
|
|
428
|
+
placeHorizontally(rightNodes, rightStart, yCenter - PILL_H / 2, GAP);
|
|
429
|
+
}
|
|
340
430
|
|
|
341
|
-
function
|
|
342
|
-
|
|
431
|
+
function rowSpan(nodes, gap) {
|
|
432
|
+
if (nodes.length === 0) return 0;
|
|
433
|
+
return nodes.reduce((a, n) => a + n.w, 0) + (nodes.length - 1) * gap;
|
|
343
434
|
}
|
|
344
435
|
|
|
345
|
-
function
|
|
346
|
-
|
|
436
|
+
function placeHorizontally(nodes, startX, y, gap) {
|
|
437
|
+
let x = startX;
|
|
438
|
+
for (const n of nodes) {
|
|
439
|
+
n.x = x;
|
|
440
|
+
n.y = y;
|
|
441
|
+
n.h = PILL_H;
|
|
442
|
+
x += n.w + gap;
|
|
443
|
+
}
|
|
347
444
|
}
|
|
348
445
|
|
|
349
|
-
function
|
|
350
|
-
|
|
446
|
+
function pillWidth(label, isSession) {
|
|
447
|
+
const minW = isSession ? SESSION_MIN_W : PILL_MIN_W;
|
|
448
|
+
const maxW = isSession ? SESSION_MAX_W : PILL_MAX_W;
|
|
449
|
+
const charW = isSession ? CHAR_W_SANS_13 : CHAR_W_SANS_12;
|
|
450
|
+
const raw = Math.ceil((label || '').length * charW) + PILL_PAD_X;
|
|
451
|
+
return clamp(raw, minW, maxW);
|
|
351
452
|
}
|
|
352
453
|
|
|
454
|
+
function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
|
|
455
|
+
|
|
353
456
|
function truncate(s, max) {
|
|
354
457
|
if (!s) return '';
|
|
355
458
|
if (s.length <= max) return s;
|
|
356
459
|
return s.slice(0, max - 1) + '…';
|
|
357
460
|
}
|
|
358
461
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
// Pack chips into rows that fit within maxW.
|
|
362
|
-
function packRows(items, maxW) {
|
|
363
|
-
const rows = [[]];
|
|
364
|
-
let cur = 0;
|
|
365
|
-
for (const it of items) {
|
|
366
|
-
if (cur > 0 && cur + it.w + 8 > maxW) {
|
|
367
|
-
rows.push([it]);
|
|
368
|
-
cur = it.w;
|
|
369
|
-
} else {
|
|
370
|
-
rows[rows.length - 1].push(it);
|
|
371
|
-
cur += (cur > 0 ? 8 : 0) + it.w;
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
return rows;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
function rowWidth(row) {
|
|
378
|
-
if (row.length === 0) return 0;
|
|
379
|
-
return row.reduce((a, c) => a + c.w, 0) + (row.length - 1) * 8;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
function pill({ key, x, y, width, label, tone }) {
|
|
383
|
-
const styles = {
|
|
384
|
-
neutral: { fill: 'var(--subtle-fill)', stroke: 'var(--border)', text: 'var(--dark)' },
|
|
385
|
-
person: { fill: 'var(--blue-chip)', stroke: 'var(--blue-muted)', text: 'var(--blue-muted)' },
|
|
386
|
-
}[tone] || { fill: 'var(--subtle-fill)', stroke: 'var(--border)', text: 'var(--dark)' };
|
|
462
|
+
// ── rendering helpers ──────────────────────────────────────────────
|
|
387
463
|
|
|
464
|
+
function renderSessionNode({ x, y, w, label, key }) {
|
|
465
|
+
const s = STYLE.session;
|
|
388
466
|
return h('g', { key },
|
|
389
467
|
h('rect', {
|
|
390
|
-
x, y, width, height:
|
|
391
|
-
rx:
|
|
392
|
-
fill:
|
|
468
|
+
x, y, width: w, height: SESSION_H,
|
|
469
|
+
rx: 10, ry: 10,
|
|
470
|
+
fill: s.fill, stroke: s.stroke, strokeWidth: 1.5,
|
|
393
471
|
}),
|
|
394
472
|
h('text', {
|
|
395
|
-
x: x +
|
|
396
|
-
|
|
473
|
+
x: x + w / 2, y: y + 18,
|
|
474
|
+
textAnchor: 'middle',
|
|
475
|
+
fontFamily: fonts.mono, fontSize: 9,
|
|
476
|
+
fill: 'var(--terracotta)',
|
|
477
|
+
letterSpacing: '0.08em',
|
|
478
|
+
}, 'THIS HANDOFF'),
|
|
479
|
+
h('text', {
|
|
480
|
+
x: x + w / 2, y: y + 40,
|
|
397
481
|
textAnchor: 'middle',
|
|
398
482
|
fontFamily: fonts.sans, fontSize: 13, fontWeight: 500,
|
|
399
|
-
fill:
|
|
483
|
+
fill: s.text,
|
|
400
484
|
}, label),
|
|
401
485
|
);
|
|
402
486
|
}
|
|
403
487
|
|
|
404
|
-
function
|
|
405
|
-
const
|
|
406
|
-
|
|
407
|
-
artifact: { fill: 'var(--blue-chip)', text: 'var(--blue-muted)' },
|
|
408
|
-
pr: { fill: 'var(--subtle-fill)', text: 'var(--dark)' },
|
|
409
|
-
}[tone] || { fill: 'var(--subtle-fill)', text: 'var(--muted)' };
|
|
410
|
-
|
|
411
|
-
return h('g', { key },
|
|
488
|
+
function renderNode({ x, y, w, label, sub, kind, key }) {
|
|
489
|
+
const s = STYLE[kind] || STYLE.project;
|
|
490
|
+
const children = [
|
|
412
491
|
h('rect', {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
492
|
+
key: 'bg',
|
|
493
|
+
x, y, width: w, height: PILL_H,
|
|
494
|
+
rx: PILL_H / 2, ry: PILL_H / 2,
|
|
495
|
+
fill: s.fill, stroke: s.stroke, strokeWidth: 1,
|
|
416
496
|
}),
|
|
417
497
|
h('text', {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
498
|
+
key: 'lbl',
|
|
499
|
+
x: x + w / 2, y: y + PILL_H / 2 + 4,
|
|
500
|
+
textAnchor: 'middle',
|
|
501
|
+
fontFamily: fonts.sans, fontSize: 12, fontWeight: 500,
|
|
502
|
+
fill: s.text,
|
|
421
503
|
}, label),
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
504
|
+
];
|
|
505
|
+
if (sub) {
|
|
506
|
+
children.push(h('text', {
|
|
507
|
+
key: 'sub',
|
|
508
|
+
x: x + w / 2, y: y + PILL_H + 11,
|
|
509
|
+
textAnchor: 'middle',
|
|
510
|
+
fontFamily: fonts.mono, fontSize: 9,
|
|
511
|
+
fill: 'var(--muted)',
|
|
512
|
+
fontStyle: 'italic',
|
|
513
|
+
}, sub));
|
|
514
|
+
}
|
|
515
|
+
return h('g', { key }, ...children);
|
|
432
516
|
}
|
|
433
517
|
|
|
434
|
-
function
|
|
518
|
+
function overflowLabel({ key, x, y, n, anchor = 'start' }) {
|
|
435
519
|
return h('text', {
|
|
436
520
|
key, x, y,
|
|
437
521
|
textAnchor: anchor,
|
|
438
522
|
fontFamily: fonts.mono, fontSize: 10,
|
|
439
523
|
fill: 'var(--muted)',
|
|
440
|
-
|
|
441
|
-
}, text);
|
|
524
|
+
}, `+${n} more`);
|
|
442
525
|
}
|
|
443
526
|
|
|
444
|
-
function
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
527
|
+
function edge({ key, x1, y1, x2, y2, label, curved, dashed }) {
|
|
528
|
+
const midx = (x1 + x2) / 2;
|
|
529
|
+
const midy = (y1 + y2) / 2;
|
|
530
|
+
const path = curved
|
|
531
|
+
? `M ${x1} ${y1} C ${x1} ${midy}, ${x2} ${midy}, ${x2} ${y2}`
|
|
532
|
+
: `M ${x1} ${y1} L ${x2} ${y2}`;
|
|
533
|
+
const strokeProps = {
|
|
534
|
+
fill: 'none',
|
|
535
|
+
stroke: 'var(--border)',
|
|
536
|
+
strokeWidth: 1.2,
|
|
537
|
+
markerEnd: 'url(#sg-arrow)',
|
|
538
|
+
};
|
|
539
|
+
if (dashed) strokeProps.strokeDasharray = '3 3';
|
|
540
|
+
const elems = [h('path', { key: 'p', d: path, ...strokeProps })];
|
|
541
|
+
if (label) {
|
|
542
|
+
// Offset label above midpoint for vertical edges, to the right for others
|
|
543
|
+
elems.push(h('text', {
|
|
544
|
+
key: 'lbl',
|
|
545
|
+
x: midx + 4, y: midy + 3,
|
|
546
|
+
fontFamily: fonts.mono, fontSize: 9,
|
|
547
|
+
fill: 'var(--muted)',
|
|
548
|
+
letterSpacing: '0.06em',
|
|
549
|
+
}, label));
|
|
550
|
+
}
|
|
551
|
+
return h('g', { key }, ...elems);
|
|
452
552
|
}
|