hoi4-agent-tools 2.1.1 → 2.3.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 +24 -0
- package/README.md +32 -18
- package/dist/bin/stdio.js +2 -1
- package/dist/hoi4_agent_tools/core/clausewitz-evaluation.js +90 -0
- package/dist/hoi4_agent_tools/core/engine.js +14 -0
- package/dist/hoi4_agent_tools/core/index.js +349 -2
- package/dist/hoi4_agent_tools/core/source/ast-path.js +41 -0
- package/dist/hoi4_agent_tools/core/source/index.js +1 -0
- package/dist/hoi4_agent_tools/event/limits.js +1 -1
- package/dist/hoi4_agent_tools/focus/render.js +37 -13
- package/dist/hoi4_agent_tools/mcp/server/create.js +5 -1
- package/dist/hoi4_agent_tools/mcp/server/result.js +4 -4
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +39 -2
- package/dist/hoi4_agent_tools/mcp/tools/probability.js +413 -0
- package/dist/hoi4_agent_tools/mcp/tools/technology.js +368 -0
- package/dist/hoi4_agent_tools/probability/adapters.js +344 -0
- package/dist/hoi4_agent_tools/probability/compare.js +178 -0
- package/dist/hoi4_agent_tools/probability/evaluation.js +649 -0
- package/dist/hoi4_agent_tools/probability/index.js +13 -0
- package/dist/hoi4_agent_tools/probability/model.js +5 -0
- package/dist/hoi4_agent_tools/probability/rational.js +145 -0
- package/dist/hoi4_agent_tools/probability/render.js +307 -0
- package/dist/hoi4_agent_tools/probability/sequence.js +858 -0
- package/dist/hoi4_agent_tools/probability/service.js +1168 -0
- package/dist/hoi4_agent_tools/probability/simulation.js +545 -0
- package/dist/hoi4_agent_tools/probability/source-analysis.js +458 -0
- package/dist/hoi4_agent_tools/probability/sweep.js +265 -0
- package/dist/hoi4_agent_tools/probability/trigger-evaluator.js +280 -0
- package/dist/hoi4_agent_tools/probability/value-evaluator.js +357 -0
- package/dist/hoi4_agent_tools/schemas/probability.js +930 -0
- package/dist/hoi4_agent_tools/schemas/technology.js +77 -0
- package/dist/hoi4_agent_tools/technology/compare.js +240 -0
- package/dist/hoi4_agent_tools/technology/graph.js +1196 -0
- package/dist/hoi4_agent_tools/technology/index.js +7 -0
- package/dist/hoi4_agent_tools/technology/model.js +2 -0
- package/dist/hoi4_agent_tools/technology/queries.js +300 -0
- package/dist/hoi4_agent_tools/technology/render.js +641 -0
- package/dist/hoi4_agent_tools/technology/service.js +686 -0
- package/dist/hoi4_agent_tools/technology/source-analysis.js +967 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +2 -0
- package/docs/development.md +7 -1
- package/docs/probability.md +80 -0
- package/docs/research/probability-adapter-evidence.md +64 -0
- package/docs/setup.md +1 -1
- package/docs/technology.md +90 -0
- package/examples/clients/codex-config.toml +3 -0
- package/examples/clients/generic.json +8 -0
- package/examples/probability/adaptive-event-pool.json +92 -0
- package/examples/probability/adaptive-event-sequence-input.json +57 -0
- package/examples/probability/event-mtth-scenarios.json +57 -0
- package/examples/probability/event-mtth-simulate-input.json +37 -0
- package/examples/probability/focus-route-scenarios.json +61 -0
- package/examples/probability/focus-route-sweep-input.json +36 -0
- package/package.json +26 -8
- package/schemas/configuration.schema.json +747 -0
- package/schemas/continuous-focus-palette.schema.json +325 -0
- package/schemas/custom-weighted-pool.schema.json +298 -0
- package/schemas/event-feature-manifest.schema.json +63 -0
- package/schemas/focus-plan.schema.json +927 -0
- package/schemas/focus-planning-sidecar.schema.json +407 -0
- package/schemas/gui-animation-source.schema.json +176 -0
- package/schemas/gui-helper.schema.json +301 -0
- package/schemas/gui-scenario.schema.json +298 -0
- package/schemas/map-operation.schema.json +2284 -0
- package/schemas/operation-result.schema.json +327 -0
- package/schemas/probability-analysis-result.schema.json +2211 -0
- package/schemas/probability-compare-input.schema.json +664 -0
- package/schemas/probability-evaluate-input.schema.json +665 -0
- package/schemas/probability-inspect-input.schema.json +97 -0
- package/schemas/probability-render-input.schema.json +141 -0
- package/schemas/probability-scenario-set.schema.json +430 -0
- package/schemas/probability-sequence-input.schema.json +886 -0
- package/schemas/probability-simulate-input.schema.json +691 -0
- package/schemas/probability-sweep-input.schema.json +702 -0
- package/server.json +3 -3
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { canonicalJson, compareCodeUnits, sha256Bytes } from '../core/canonical.js';
|
|
3
|
+
import { RenderBudget, RENDER_MAX_PIXELS } from '../core/render-budget.js';
|
|
4
|
+
import { ServiceError } from '../core/result.js';
|
|
5
|
+
import { DeterministicSvgTextRenderer } from '../core/svg-text.js';
|
|
6
|
+
import { traceTechnology } from './queries.js';
|
|
7
|
+
const MAX_RENDER_NODES = 2_000;
|
|
8
|
+
const NODE_WIDTH = 210;
|
|
9
|
+
const NODE_HEIGHT = 76;
|
|
10
|
+
const GAP_X = 42;
|
|
11
|
+
const GAP_Y = 42;
|
|
12
|
+
const GENERATED_NODE_WIDTH = 180;
|
|
13
|
+
const GENERATED_NODE_HEIGHT = 64;
|
|
14
|
+
const GENERATED_GAP = 24;
|
|
15
|
+
const GENERATED_LAYER_COLUMNS = 40;
|
|
16
|
+
const GENERATED_LAYER_ROWS = 32;
|
|
17
|
+
const GENERATED_BAND_GAP = 32;
|
|
18
|
+
const PADDING = 42;
|
|
19
|
+
const HEADER_HEIGHT = 86;
|
|
20
|
+
function escapeXml(value) {
|
|
21
|
+
return value
|
|
22
|
+
.replaceAll('&', '&')
|
|
23
|
+
.replaceAll('<', '<')
|
|
24
|
+
.replaceAll('>', '>')
|
|
25
|
+
.replaceAll('"', '"')
|
|
26
|
+
.replaceAll("'", ''');
|
|
27
|
+
}
|
|
28
|
+
function technologyNode(graph, technologyId, placement) {
|
|
29
|
+
const technology = graph.technologies.find(({ id }) => id === technologyId);
|
|
30
|
+
if (technology === undefined)
|
|
31
|
+
return undefined;
|
|
32
|
+
return {
|
|
33
|
+
id: technology.id,
|
|
34
|
+
label: technology.localisation.name ?? technology.id,
|
|
35
|
+
subtitle: [
|
|
36
|
+
technology.startYear === undefined ? undefined : `year ${technology.startYear}`,
|
|
37
|
+
technology.researchCost === undefined ? undefined : `cost ${technology.researchCost}`,
|
|
38
|
+
technology.icon.status === 'resolved'
|
|
39
|
+
? undefined
|
|
40
|
+
: technology.icon.status.replaceAll('_', ' '),
|
|
41
|
+
]
|
|
42
|
+
.filter((value) => value !== undefined)
|
|
43
|
+
.join(' · '),
|
|
44
|
+
kind: technology.kind,
|
|
45
|
+
sourcePath: technology.source.path,
|
|
46
|
+
sourceLine: technology.source.location.start.line,
|
|
47
|
+
...(placement === undefined ? {} : { placement }),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function sortedUniqueNodes(values) {
|
|
51
|
+
const result = new Map();
|
|
52
|
+
for (const value of values)
|
|
53
|
+
if (!result.has(value.id))
|
|
54
|
+
result.set(value.id, value);
|
|
55
|
+
return [...result.values()].sort((left, right) => compareCodeUnits(left.id, right.id));
|
|
56
|
+
}
|
|
57
|
+
function viewSelection(graph, options) {
|
|
58
|
+
const nodes = [];
|
|
59
|
+
const edges = [];
|
|
60
|
+
let title;
|
|
61
|
+
let sourceAccurate = false;
|
|
62
|
+
if (options.view === 'summary') {
|
|
63
|
+
title = 'Technology workspace summary';
|
|
64
|
+
for (const folder of graph.folders) {
|
|
65
|
+
nodes.push({
|
|
66
|
+
id: `folder:${folder.id}`,
|
|
67
|
+
label: folder.localisation.name ?? folder.id,
|
|
68
|
+
subtitle: `${graph.placements.filter(({ folderId }) => folderId === folder.id).length} placements${folder.doctrine ? ' · legacy doctrine' : ''}`,
|
|
69
|
+
kind: 'folder',
|
|
70
|
+
sourcePath: folder.source.path,
|
|
71
|
+
sourceLine: folder.source.location.start.line,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
for (const folder of graph.doctrineDefinitions.filter(({ kind }) => kind === 'folder'))
|
|
75
|
+
nodes.push({
|
|
76
|
+
id: `doctrine-folder:${folder.id}`,
|
|
77
|
+
label: folder.nameKey ?? folder.id,
|
|
78
|
+
subtitle: 'modern doctrine folder',
|
|
79
|
+
kind: 'doctrine',
|
|
80
|
+
sourcePath: folder.source.path,
|
|
81
|
+
sourceLine: folder.source.location.start.line,
|
|
82
|
+
});
|
|
83
|
+
for (const classification of [
|
|
84
|
+
'confirmed_error',
|
|
85
|
+
'probable_defect',
|
|
86
|
+
'design_warning',
|
|
87
|
+
'unresolved_analysis',
|
|
88
|
+
]) {
|
|
89
|
+
const count = graph.issues.filter((issue) => issue.classification === classification).length;
|
|
90
|
+
if (count > 0)
|
|
91
|
+
nodes.push({
|
|
92
|
+
id: `issues:${classification}`,
|
|
93
|
+
label: classification.replaceAll('_', ' '),
|
|
94
|
+
subtitle: `${count} findings`,
|
|
95
|
+
kind: classification === 'unresolved_analysis' ? 'unresolved' : 'issue',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else if (options.view === 'folder') {
|
|
100
|
+
if (options.folderId === undefined)
|
|
101
|
+
throw new ServiceError('TECH_RENDER_FOLDER_REQUIRED', 'Folder rendering requires folderId');
|
|
102
|
+
title = `Source folder layout · ${options.folderId}`;
|
|
103
|
+
const placements = graph.placements.filter(({ folderId }) => folderId === options.folderId);
|
|
104
|
+
if (placements.length === 0)
|
|
105
|
+
throw new ServiceError('TECH_RENDER_FOLDER_EMPTY', `Technology folder ${options.folderId} has no source placements`);
|
|
106
|
+
for (const placement of placements) {
|
|
107
|
+
const node = technologyNode(graph, placement.technologyId, placement);
|
|
108
|
+
if (node !== undefined)
|
|
109
|
+
nodes.push(node);
|
|
110
|
+
}
|
|
111
|
+
const selected = new Set(nodes.map(({ id }) => id));
|
|
112
|
+
edges.push(...graph.edges
|
|
113
|
+
.filter(({ from, to }) => selected.has(from) && selected.has(to))
|
|
114
|
+
.map(({ id, from, to, kind }) => ({ id, from, to, kind })));
|
|
115
|
+
sourceAccurate = true;
|
|
116
|
+
}
|
|
117
|
+
else if (options.view === 'dependencies' || options.view === 'technology') {
|
|
118
|
+
const ids = options.technologyId === undefined
|
|
119
|
+
? graph.technologies.map(({ id }) => id)
|
|
120
|
+
: traceTechnology(graph, {
|
|
121
|
+
technologyId: options.technologyId,
|
|
122
|
+
direction: 'both',
|
|
123
|
+
maxDepth: 64,
|
|
124
|
+
maxNodes: options.maxNodes ?? 1_000,
|
|
125
|
+
includeSubTechnologies: true,
|
|
126
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
127
|
+
}).nodes;
|
|
128
|
+
title =
|
|
129
|
+
options.technologyId === undefined
|
|
130
|
+
? 'Generated semantic dependency overview'
|
|
131
|
+
: `Generated dependency view · ${options.technologyId}`;
|
|
132
|
+
for (const id of ids) {
|
|
133
|
+
const node = technologyNode(graph, id);
|
|
134
|
+
if (node !== undefined)
|
|
135
|
+
nodes.push(node);
|
|
136
|
+
}
|
|
137
|
+
const selected = new Set(ids);
|
|
138
|
+
edges.push(...graph.edges
|
|
139
|
+
.filter(({ from, to }) => selected.has(from) && selected.has(to))
|
|
140
|
+
.map(({ id, from, to, kind }) => ({ id, from, to, kind })));
|
|
141
|
+
}
|
|
142
|
+
else if (options.view === 'doctrine') {
|
|
143
|
+
title = 'Doctrine branches';
|
|
144
|
+
for (const definition of graph.doctrineDefinitions.filter(({ folderId, id }) => options.folderId === undefined || folderId === options.folderId || id === options.folderId))
|
|
145
|
+
nodes.push({
|
|
146
|
+
id: `doctrine:${definition.kind}:${definition.id}`,
|
|
147
|
+
label: definition.nameKey ?? definition.id,
|
|
148
|
+
subtitle: `${definition.kind.replaceAll('_', ' ')}${definition.xpCost === undefined ? '' : ` · ${definition.xpCost} ${definition.xpType ?? 'xp'}`}`,
|
|
149
|
+
kind: 'doctrine',
|
|
150
|
+
sourcePath: definition.source.path,
|
|
151
|
+
sourceLine: definition.source.location.start.line,
|
|
152
|
+
});
|
|
153
|
+
for (const definition of graph.doctrineDefinitions) {
|
|
154
|
+
const from = `doctrine:${definition.kind}:${definition.id}`;
|
|
155
|
+
if (!nodes.some(({ id }) => id === from))
|
|
156
|
+
continue;
|
|
157
|
+
for (const trackId of definition.trackIds) {
|
|
158
|
+
const target = graph.doctrineDefinitions.find(({ kind, id }) => kind === 'track' && id === trackId);
|
|
159
|
+
if (target !== undefined)
|
|
160
|
+
edges.push({
|
|
161
|
+
id: `${from}:track:${trackId}`,
|
|
162
|
+
from,
|
|
163
|
+
to: `doctrine:track:${trackId}`,
|
|
164
|
+
kind: 'track',
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (definition.parentId !== undefined) {
|
|
168
|
+
const parent = graph.doctrineDefinitions.find(({ id }) => id === definition.parentId);
|
|
169
|
+
if (parent !== undefined)
|
|
170
|
+
edges.push({
|
|
171
|
+
id: `${from}:parent:${definition.parentId}`,
|
|
172
|
+
from: `doctrine:${parent.kind}:${parent.id}`,
|
|
173
|
+
to: from,
|
|
174
|
+
kind: 'reward',
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (const technology of graph.technologies.filter(({ kind }) => kind === 'legacy_doctrine')) {
|
|
179
|
+
const node = technologyNode(graph, technology.id);
|
|
180
|
+
if (node !== undefined)
|
|
181
|
+
nodes.push(node);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else if (options.view === 'exclusive') {
|
|
185
|
+
title = 'Exclusive technology and doctrine choices';
|
|
186
|
+
const exclusiveEdges = graph.edges.filter(({ kind }) => kind === 'exclusive');
|
|
187
|
+
const ids = new Set(exclusiveEdges.flatMap(({ from, to }) => [from, to]));
|
|
188
|
+
for (const id of ids) {
|
|
189
|
+
const node = technologyNode(graph, id);
|
|
190
|
+
if (node !== undefined)
|
|
191
|
+
nodes.push(node);
|
|
192
|
+
}
|
|
193
|
+
edges.push(...exclusiveEdges.map(({ id, from, to, kind }) => ({ id, from, to, kind })));
|
|
194
|
+
for (const doctrine of graph.doctrineDefinitions.filter(({ exclusiveIds }) => exclusiveIds.length > 0)) {
|
|
195
|
+
const from = `doctrine:${doctrine.kind}:${doctrine.id}`;
|
|
196
|
+
nodes.push({
|
|
197
|
+
id: from,
|
|
198
|
+
label: doctrine.nameKey ?? doctrine.id,
|
|
199
|
+
subtitle: doctrine.kind,
|
|
200
|
+
kind: 'doctrine',
|
|
201
|
+
sourcePath: doctrine.source.path,
|
|
202
|
+
sourceLine: doctrine.source.location.start.line,
|
|
203
|
+
});
|
|
204
|
+
for (const targetId of doctrine.exclusiveIds) {
|
|
205
|
+
const target = graph.doctrineDefinitions.find(({ id }) => id === targetId);
|
|
206
|
+
const to = target === undefined ? `unresolved:${targetId}` : `doctrine:${target.kind}:${target.id}`;
|
|
207
|
+
if (!nodes.some(({ id }) => id === to))
|
|
208
|
+
nodes.push({
|
|
209
|
+
id: to,
|
|
210
|
+
label: target?.nameKey ?? targetId,
|
|
211
|
+
subtitle: target?.kind ?? 'missing target',
|
|
212
|
+
kind: target === undefined ? 'unresolved' : 'doctrine',
|
|
213
|
+
...(target === undefined
|
|
214
|
+
? {}
|
|
215
|
+
: { sourcePath: target.source.path, sourceLine: target.source.location.start.line }),
|
|
216
|
+
});
|
|
217
|
+
edges.push({ id: `${from}:xor:${to}`, from, to, kind: 'exclusive' });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else if (options.view === 'memberships') {
|
|
222
|
+
title = 'Technology category membership matrix';
|
|
223
|
+
const categories = graph.categories.filter(({ id }) => options.categoryId === undefined || id === options.categoryId);
|
|
224
|
+
for (const category of categories)
|
|
225
|
+
nodes.push({
|
|
226
|
+
id: `category:${category.id}`,
|
|
227
|
+
label: category.localisation ?? category.id,
|
|
228
|
+
subtitle: `${graph.technologies.filter(({ categories }) => categories.includes(category.id)).length} technologies`,
|
|
229
|
+
kind: 'category',
|
|
230
|
+
sourcePath: category.source.path,
|
|
231
|
+
sourceLine: category.source.location.start.line,
|
|
232
|
+
});
|
|
233
|
+
for (const technology of graph.technologies) {
|
|
234
|
+
const memberships = technology.categories.filter((category) => categories.some(({ id }) => id === category));
|
|
235
|
+
if (memberships.length === 0)
|
|
236
|
+
continue;
|
|
237
|
+
const node = technologyNode(graph, technology.id);
|
|
238
|
+
if (node !== undefined)
|
|
239
|
+
nodes.push(node);
|
|
240
|
+
for (const category of memberships)
|
|
241
|
+
edges.push({
|
|
242
|
+
id: `membership:${technology.id}:${category}`,
|
|
243
|
+
from: `category:${category}`,
|
|
244
|
+
to: technology.id,
|
|
245
|
+
kind: 'membership',
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
else if (options.view === 'bonuses' || options.view === 'grants') {
|
|
250
|
+
const referenceKinds = options.view === 'bonuses'
|
|
251
|
+
? new Set(['research_bonus', 'technology_sharing'])
|
|
252
|
+
: new Set(['grant', 'starting_technology']);
|
|
253
|
+
title =
|
|
254
|
+
options.view === 'bonuses'
|
|
255
|
+
? 'Research bonus coverage'
|
|
256
|
+
: 'External and starting technology grants';
|
|
257
|
+
const references = graph.externalReferences.filter(({ kind, technologyId, categoryId }) => referenceKinds.has(kind) &&
|
|
258
|
+
(options.technologyId === undefined || technologyId === options.technologyId) &&
|
|
259
|
+
(options.categoryId === undefined || categoryId === options.categoryId));
|
|
260
|
+
for (const reference of references) {
|
|
261
|
+
const sourceId = `source:${reference.sourceKind}:${reference.sourceId}`;
|
|
262
|
+
if (!nodes.some(({ id }) => id === sourceId))
|
|
263
|
+
nodes.push({
|
|
264
|
+
id: sourceId,
|
|
265
|
+
label: reference.sourceId,
|
|
266
|
+
subtitle: reference.sourceKind.replaceAll('_', ' '),
|
|
267
|
+
kind: 'source',
|
|
268
|
+
sourcePath: reference.location.path,
|
|
269
|
+
sourceLine: reference.location.start.line,
|
|
270
|
+
});
|
|
271
|
+
const targetId = reference.technologyId ?? `category:${reference.categoryId ?? '<dynamic>'}`;
|
|
272
|
+
if (!nodes.some(({ id }) => id === targetId)) {
|
|
273
|
+
const technology = reference.technologyId === undefined ? undefined : technologyNode(graph, targetId);
|
|
274
|
+
nodes.push(technology ?? {
|
|
275
|
+
id: targetId,
|
|
276
|
+
label: reference.categoryId ?? reference.expression,
|
|
277
|
+
subtitle: reference.dynamic ? 'dynamic unresolved target' : 'technology category',
|
|
278
|
+
kind: reference.dynamic ? 'unresolved' : 'category',
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
edges.push({ id: reference.id, from: sourceId, to: targetId, kind: reference.kind });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
else if (options.view === 'unlocks') {
|
|
285
|
+
title = 'Technology unlock impact';
|
|
286
|
+
const unlocks = graph.unlocks.filter(({ technologyId, targetId }) => (options.technologyId === undefined || technologyId === options.technologyId) &&
|
|
287
|
+
(options.targetId === undefined || targetId === options.targetId));
|
|
288
|
+
for (const unlock of unlocks) {
|
|
289
|
+
const technology = technologyNode(graph, unlock.technologyId);
|
|
290
|
+
if (technology !== undefined)
|
|
291
|
+
nodes.push(technology);
|
|
292
|
+
const targetId = `unlock:${unlock.kind}:${unlock.targetId}`;
|
|
293
|
+
if (!nodes.some(({ id }) => id === targetId))
|
|
294
|
+
nodes.push({
|
|
295
|
+
id: targetId,
|
|
296
|
+
label: unlock.targetId,
|
|
297
|
+
subtitle: `${unlock.kind.replaceAll('_', ' ')}${unlock.resolved === false ? ' · missing' : ''}`,
|
|
298
|
+
kind: unlock.resolved === false ? 'unresolved' : 'unlock',
|
|
299
|
+
});
|
|
300
|
+
edges.push({ id: unlock.id, from: unlock.technologyId, to: targetId, kind: 'unlocks' });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
else if (options.view === 'metadata') {
|
|
304
|
+
title = 'Technology year, cost, and AI metadata';
|
|
305
|
+
for (const technology of graph.technologies) {
|
|
306
|
+
const node = technologyNode(graph, technology.id);
|
|
307
|
+
if (node !== undefined)
|
|
308
|
+
nodes.push({
|
|
309
|
+
...node,
|
|
310
|
+
subtitle: `year ${technology.startYear ?? '?'} · cost ${technology.researchCost ?? '?'} · AI ${technology.ai.present ? (technology.ai.zero === true ? 'zero' : 'defined') : 'missing'}`,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
edges.push(...graph.edges
|
|
314
|
+
.filter(({ kind }) => kind === 'prerequisite')
|
|
315
|
+
.map(({ id, from, to, kind }) => ({ id, from, to, kind })));
|
|
316
|
+
}
|
|
317
|
+
else if (options.view === 'assets') {
|
|
318
|
+
title = 'Technology icon and localisation coverage';
|
|
319
|
+
for (const technology of graph.technologies.filter(({ icon, localisation }) => icon.status !== 'resolved' || localisation.status !== 'resolved')) {
|
|
320
|
+
const node = technologyNode(graph, technology.id);
|
|
321
|
+
if (node !== undefined)
|
|
322
|
+
nodes.push({
|
|
323
|
+
...node,
|
|
324
|
+
subtitle: `icon ${technology.icon.status.replaceAll('_', ' ')} · localisation ${technology.localisation.status}`,
|
|
325
|
+
kind: technology.icon.status === 'partial' || technology.localisation.status === 'partial'
|
|
326
|
+
? 'unresolved'
|
|
327
|
+
: 'issue',
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
else if (options.view === 'unresolved') {
|
|
332
|
+
title = 'Unresolved technology analysis';
|
|
333
|
+
for (const item of graph.unresolved)
|
|
334
|
+
nodes.push({
|
|
335
|
+
id: item.id,
|
|
336
|
+
label: item.expression,
|
|
337
|
+
subtitle: item.kind.replaceAll('_', ' '),
|
|
338
|
+
kind: 'unresolved',
|
|
339
|
+
...(item.location === undefined
|
|
340
|
+
? {}
|
|
341
|
+
: { sourcePath: item.location.path, sourceLine: item.location.start.line }),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
if (options.comparison === undefined)
|
|
346
|
+
throw new ServiceError('TECH_RENDER_COMPARISON_REQUIRED', 'Comparison rendering requires a technology comparison');
|
|
347
|
+
title = 'Technology structural comparison';
|
|
348
|
+
for (const id of options.comparison.technologies.added)
|
|
349
|
+
nodes.push({ id: `added:${id}`, label: id, subtitle: 'technology added', kind: 'added' });
|
|
350
|
+
for (const id of options.comparison.technologies.removed)
|
|
351
|
+
nodes.push({
|
|
352
|
+
id: `removed:${id}`,
|
|
353
|
+
label: id,
|
|
354
|
+
subtitle: 'technology removed',
|
|
355
|
+
kind: 'removed',
|
|
356
|
+
});
|
|
357
|
+
for (const rename of options.comparison.technologies.renamed) {
|
|
358
|
+
const from = `removed:${rename.beforeId}`;
|
|
359
|
+
const to = `added:${rename.afterId}`;
|
|
360
|
+
if (!nodes.some(({ id }) => id === from))
|
|
361
|
+
nodes.push({
|
|
362
|
+
id: from,
|
|
363
|
+
label: rename.beforeId,
|
|
364
|
+
subtitle: 'rename source',
|
|
365
|
+
kind: 'removed',
|
|
366
|
+
});
|
|
367
|
+
if (!nodes.some(({ id }) => id === to))
|
|
368
|
+
nodes.push({
|
|
369
|
+
id: to,
|
|
370
|
+
label: rename.afterId,
|
|
371
|
+
subtitle: `rename candidate · ${rename.confidence}`,
|
|
372
|
+
kind: 'added',
|
|
373
|
+
});
|
|
374
|
+
edges.push({
|
|
375
|
+
id: `rename:${rename.beforeId}:${rename.afterId}`,
|
|
376
|
+
from,
|
|
377
|
+
to,
|
|
378
|
+
kind: 'rename candidate',
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
for (const issue of options.comparison.issues.introduced.slice(0, 200))
|
|
382
|
+
nodes.push({
|
|
383
|
+
id: `introduced:${issueKey(issue)}`,
|
|
384
|
+
label: issue.code,
|
|
385
|
+
subtitle: issue.message,
|
|
386
|
+
kind: 'issue',
|
|
387
|
+
...(issue.location === undefined
|
|
388
|
+
? {}
|
|
389
|
+
: { sourcePath: issue.location.path, sourceLine: issue.location.start.line }),
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
const selectedNodes = sortedUniqueNodes(nodes);
|
|
393
|
+
const selectedIds = new Set(selectedNodes.map(({ id }) => id));
|
|
394
|
+
const selectedEdges = edges
|
|
395
|
+
.filter(({ from, to }) => selectedIds.has(from) && selectedIds.has(to))
|
|
396
|
+
.sort((left, right) => compareCodeUnits(left.id, right.id));
|
|
397
|
+
return {
|
|
398
|
+
nodes: selectedNodes,
|
|
399
|
+
edges: selectedEdges,
|
|
400
|
+
sourceAccurate,
|
|
401
|
+
title,
|
|
402
|
+
payload: {
|
|
403
|
+
view: options.view,
|
|
404
|
+
title,
|
|
405
|
+
sourceAccurate,
|
|
406
|
+
generatedAnalysisLayout: !sourceAccurate,
|
|
407
|
+
nodes: selectedNodes,
|
|
408
|
+
edges: selectedEdges,
|
|
409
|
+
graphRevision: graph.revision,
|
|
410
|
+
analysisBoundary: graph.analysisBoundary,
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
function issueKey(issue) {
|
|
415
|
+
return sha256Bytes(canonicalJson({ code: issue.code, details: issue.details })).slice(0, 20);
|
|
416
|
+
}
|
|
417
|
+
function layoutFolder(nodes) {
|
|
418
|
+
const sourcePixel = nodes.filter(({ placement }) => placement?.pixelX !== undefined && placement.pixelY !== undefined);
|
|
419
|
+
if (sourcePixel.length === nodes.length && sourcePixel.length > 0) {
|
|
420
|
+
const minimumX = Math.min(...sourcePixel.map(({ placement }) => placement.pixelX));
|
|
421
|
+
const minimumY = Math.min(...sourcePixel.map(({ placement }) => placement.pixelY));
|
|
422
|
+
return sourcePixel.map((node) => ({
|
|
423
|
+
...node,
|
|
424
|
+
x: PADDING + node.placement.pixelX - minimumX,
|
|
425
|
+
y: HEADER_HEIGHT + PADDING + node.placement.pixelY - minimumY,
|
|
426
|
+
width: NODE_WIDTH,
|
|
427
|
+
height: NODE_HEIGHT,
|
|
428
|
+
}));
|
|
429
|
+
}
|
|
430
|
+
const branchRoots = [
|
|
431
|
+
...new Set(nodes.map(({ placement }) => placement?.branchRootId ?? '<unresolved>')),
|
|
432
|
+
].sort(compareCodeUnits);
|
|
433
|
+
const lanes = new Map(branchRoots.map((id, index) => [id, index]));
|
|
434
|
+
return nodes.map((node, index) => {
|
|
435
|
+
const placement = node.placement;
|
|
436
|
+
const lane = lanes.get(placement?.branchRootId ?? '<unresolved>') ?? 0;
|
|
437
|
+
const x = PADDING + lane * 680 + (placement?.x ?? index % 3) * (NODE_WIDTH + GAP_X);
|
|
438
|
+
const y = HEADER_HEIGHT + PADDING + (placement?.y ?? Math.floor(index / 3)) * (NODE_HEIGHT + GAP_Y);
|
|
439
|
+
return { ...node, x, y, width: NODE_WIDTH, height: NODE_HEIGHT };
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
function layoutGenerated(nodes, edges) {
|
|
443
|
+
const ids = new Set(nodes.map(({ id }) => id));
|
|
444
|
+
const incoming = new Map(nodes.map(({ id }) => [id, 0]));
|
|
445
|
+
const outgoing = new Map(nodes.map(({ id }) => [id, []]));
|
|
446
|
+
const directionalEdges = edges.filter(({ kind }) => !kind.includes('exclusive') && kind !== 'membership' && kind !== 'rename candidate');
|
|
447
|
+
for (const edge of directionalEdges) {
|
|
448
|
+
if (!ids.has(edge.from) || !ids.has(edge.to))
|
|
449
|
+
continue;
|
|
450
|
+
incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1);
|
|
451
|
+
outgoing.get(edge.from).push(edge.to);
|
|
452
|
+
}
|
|
453
|
+
const layer = new Map();
|
|
454
|
+
const pending = [...nodes]
|
|
455
|
+
.filter(({ id }) => (incoming.get(id) ?? 0) === 0)
|
|
456
|
+
.map(({ id }) => id)
|
|
457
|
+
.sort(compareCodeUnits);
|
|
458
|
+
for (const id of pending)
|
|
459
|
+
layer.set(id, 0);
|
|
460
|
+
let cursor = 0;
|
|
461
|
+
while (cursor < pending.length) {
|
|
462
|
+
const current = pending[cursor++];
|
|
463
|
+
for (const target of (outgoing.get(current) ?? []).sort(compareCodeUnits)) {
|
|
464
|
+
layer.set(target, Math.max(layer.get(target) ?? 0, (layer.get(current) ?? 0) + 1));
|
|
465
|
+
incoming.set(target, (incoming.get(target) ?? 1) - 1);
|
|
466
|
+
if (incoming.get(target) === 0)
|
|
467
|
+
pending.push(target);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
for (const node of nodes)
|
|
471
|
+
if (!layer.has(node.id))
|
|
472
|
+
layer.set(node.id, 0);
|
|
473
|
+
const byLayer = new Map();
|
|
474
|
+
for (const node of nodes) {
|
|
475
|
+
const depth = layer.get(node.id) ?? 0;
|
|
476
|
+
const group = byLayer.get(depth) ?? [];
|
|
477
|
+
group.push(node);
|
|
478
|
+
byLayer.set(depth, group);
|
|
479
|
+
}
|
|
480
|
+
const result = [];
|
|
481
|
+
const orderedLayers = [...byLayer].sort(([left], [right]) => left - right);
|
|
482
|
+
const visualColumns = orderedLayers.flatMap(([, group]) => {
|
|
483
|
+
const ordered = group.sort((left, right) => compareCodeUnits(left.id, right.id));
|
|
484
|
+
const chunks = [];
|
|
485
|
+
for (let offset = 0; offset < ordered.length; offset += GENERATED_LAYER_ROWS)
|
|
486
|
+
chunks.push(ordered.slice(offset, offset + GENERATED_LAYER_ROWS));
|
|
487
|
+
return chunks;
|
|
488
|
+
});
|
|
489
|
+
const bandHeights = [];
|
|
490
|
+
for (const [columnIndex, group] of visualColumns.entries()) {
|
|
491
|
+
const band = Math.floor(columnIndex / GENERATED_LAYER_COLUMNS);
|
|
492
|
+
bandHeights[band] = Math.max(bandHeights[band] ?? 0, group.length);
|
|
493
|
+
}
|
|
494
|
+
const bandOffsets = bandHeights.map((_, band) => bandHeights
|
|
495
|
+
.slice(0, band)
|
|
496
|
+
.reduce((total, rows) => total + rows * (GENERATED_NODE_HEIGHT + GENERATED_GAP) + GENERATED_BAND_GAP, 0));
|
|
497
|
+
for (const [columnIndex, group] of visualColumns.entries()) {
|
|
498
|
+
const band = Math.floor(columnIndex / GENERATED_LAYER_COLUMNS);
|
|
499
|
+
const column = columnIndex % GENERATED_LAYER_COLUMNS;
|
|
500
|
+
for (const [row, node] of group.entries())
|
|
501
|
+
result.push({
|
|
502
|
+
...node,
|
|
503
|
+
x: PADDING + column * (GENERATED_NODE_WIDTH + GENERATED_GAP),
|
|
504
|
+
y: HEADER_HEIGHT +
|
|
505
|
+
PADDING +
|
|
506
|
+
(bandOffsets[band] ?? 0) +
|
|
507
|
+
row * (GENERATED_NODE_HEIGHT + GENERATED_GAP),
|
|
508
|
+
width: GENERATED_NODE_WIDTH,
|
|
509
|
+
height: GENERATED_NODE_HEIGHT,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
return result;
|
|
513
|
+
}
|
|
514
|
+
function colours(kind) {
|
|
515
|
+
return {
|
|
516
|
+
technology: { fill: '#16324a', stroke: '#6bb9e8' },
|
|
517
|
+
legacy_doctrine: { fill: '#352a4a', stroke: '#bd8bed' },
|
|
518
|
+
folder: { fill: '#2d3c26', stroke: '#8fc86e' },
|
|
519
|
+
category: { fill: '#3a351e', stroke: '#dbc55a' },
|
|
520
|
+
doctrine: { fill: '#34254a', stroke: '#c294ef' },
|
|
521
|
+
source: { fill: '#183d37', stroke: '#6bd5c3' },
|
|
522
|
+
unlock: { fill: '#3a2e20', stroke: '#e2a75f' },
|
|
523
|
+
issue: { fill: '#4a2828', stroke: '#ef7d7d' },
|
|
524
|
+
unresolved: { fill: '#403b43', stroke: '#c6a8d3' },
|
|
525
|
+
added: { fill: '#1e452d', stroke: '#6bdb91' },
|
|
526
|
+
removed: { fill: '#4a2828', stroke: '#ef7d7d' },
|
|
527
|
+
}[kind];
|
|
528
|
+
}
|
|
529
|
+
function svgFor(titleText, positioned, edges, sourceAccurate) {
|
|
530
|
+
const maximumX = Math.max(PADDING + NODE_WIDTH, ...positioned.map(({ x, width }) => x + width));
|
|
531
|
+
const maximumY = Math.max(HEADER_HEIGHT + NODE_HEIGHT, ...positioned.map(({ y, height }) => y + height));
|
|
532
|
+
const width = Math.ceil(maximumX + PADDING);
|
|
533
|
+
const height = Math.ceil(maximumY + PADDING);
|
|
534
|
+
if (width > 16_384 || height > 16_384 || width * height > RENDER_MAX_PIXELS)
|
|
535
|
+
throw new ServiceError('TECH_RENDER_DIMENSIONS_BLOCKED', 'Technology render exceeds fixed dimensions', {
|
|
536
|
+
width,
|
|
537
|
+
height,
|
|
538
|
+
});
|
|
539
|
+
const byId = new Map(positioned.map((node) => [node.id, node]));
|
|
540
|
+
const text = new DeterministicSvgTextRenderer();
|
|
541
|
+
const edgeSvg = edges.flatMap((edge) => {
|
|
542
|
+
const from = byId.get(edge.from);
|
|
543
|
+
const to = byId.get(edge.to);
|
|
544
|
+
if (from === undefined || to === undefined)
|
|
545
|
+
return [];
|
|
546
|
+
const x1 = from.x + from.width;
|
|
547
|
+
const y1 = from.y + from.height / 2;
|
|
548
|
+
const x2 = to.x;
|
|
549
|
+
const y2 = to.y + to.height / 2;
|
|
550
|
+
const middle = (x1 + x2) / 2;
|
|
551
|
+
const colour = edge.kind.includes('exclusive') ? '#e27878' : '#8298ad';
|
|
552
|
+
const dash = edge.kind.includes('exclusive') ? ' stroke-dasharray="7 5"' : '';
|
|
553
|
+
return `<path d="M ${x1} ${y1} C ${middle} ${y1}, ${middle} ${y2}, ${x2} ${y2}" fill="none" stroke="${colour}" stroke-width="2" marker-end="url(#tech-arrow)"${dash}><title>${escapeXml(edge.kind)}</title></path>`;
|
|
554
|
+
});
|
|
555
|
+
const nodeSvg = positioned.map((node) => {
|
|
556
|
+
const colour = colours(node.kind);
|
|
557
|
+
const label = node.label.length > 34 ? `${node.label.slice(0, 31)}…` : node.label;
|
|
558
|
+
const subtitle = node.subtitle.length > 48 ? `${node.subtitle.slice(0, 45)}…` : node.subtitle;
|
|
559
|
+
const labelSvg = text.render(label, {
|
|
560
|
+
x: node.x + 12,
|
|
561
|
+
y: node.y + 30,
|
|
562
|
+
fontSize: 15,
|
|
563
|
+
fill: '#f1f5f8',
|
|
564
|
+
weight: 600,
|
|
565
|
+
targetWidth: Math.min(node.width - 24, Math.max(1, text.measure(label, 15))),
|
|
566
|
+
});
|
|
567
|
+
const subtitleSvg = text.render(subtitle, {
|
|
568
|
+
x: node.x + 12,
|
|
569
|
+
y: node.y + 56,
|
|
570
|
+
fontSize: 11,
|
|
571
|
+
fill: '#b8c4ce',
|
|
572
|
+
targetWidth: Math.min(node.width - 24, Math.max(1, text.measure(subtitle, 11))),
|
|
573
|
+
});
|
|
574
|
+
return `<g data-node-id="${escapeXml(node.id)}"${node.sourcePath === undefined ? '' : ` data-source-path="${escapeXml(node.sourcePath)}" data-source-line="${node.sourceLine ?? 1}"`}><rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="8" fill="${colour.fill}" stroke="${colour.stroke}" stroke-width="2"/><title>${escapeXml(`${node.id}${node.sourcePath === undefined ? '' : ` · ${node.sourcePath}:${node.sourceLine ?? 1}`}`)}</title>${labelSvg}${subtitleSvg}</g>`;
|
|
575
|
+
});
|
|
576
|
+
const title = text.render(titleText, {
|
|
577
|
+
x: PADDING,
|
|
578
|
+
y: 38,
|
|
579
|
+
fontSize: 24,
|
|
580
|
+
fill: '#f5f8fa',
|
|
581
|
+
weight: 700,
|
|
582
|
+
});
|
|
583
|
+
const mode = text.render(sourceAccurate ? 'SOURCE-ACCURATE PLACEMENT' : 'GENERATED ANALYSIS LAYOUT', { x: PADDING, y: 66, fontSize: 12, fill: sourceAccurate ? '#79d29b' : '#e0b66b', weight: 600 });
|
|
584
|
+
return {
|
|
585
|
+
width,
|
|
586
|
+
height,
|
|
587
|
+
svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeXml(titleText)}"><defs><marker id="tech-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="#8298ad"/></marker>${text.definitions()}</defs><rect width="100%" height="100%" fill="#0d1721"/>${title}${mode}<g>${edgeSvg.join('')}</g><g>${nodeSvg.join('')}</g></svg>`,
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
function htmlFor(title, svg, json) {
|
|
591
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeXml(title)}</title><style>html,body{margin:0;background:#0d1721;color:#f1f5f8;font-family:system-ui,sans-serif}main{padding:16px}details{margin-top:16px}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#111e29;padding:12px}</style></head><body><main>${svg}<details><summary>Authoritative JSON</summary><pre>${escapeXml(json)}</pre></details></main></body></html>`;
|
|
592
|
+
}
|
|
593
|
+
export async function renderTechnologyGraph(graph, options) {
|
|
594
|
+
options.signal?.throwIfAborted();
|
|
595
|
+
const maximum = Math.max(1, Math.min(options.maxNodes ?? 600, MAX_RENDER_NODES));
|
|
596
|
+
const selection = viewSelection(graph, options);
|
|
597
|
+
const retained = selection.nodes.slice(0, maximum);
|
|
598
|
+
const retainedIds = new Set(retained.map(({ id }) => id));
|
|
599
|
+
const edges = selection.edges.filter(({ from, to }) => retainedIds.has(from) && retainedIds.has(to));
|
|
600
|
+
const positioned = selection.sourceAccurate
|
|
601
|
+
? layoutFolder(retained)
|
|
602
|
+
: layoutGenerated(retained, edges);
|
|
603
|
+
const rendered = svgFor(selection.title, positioned, edges, selection.sourceAccurate);
|
|
604
|
+
const budget = options.budget ?? new RenderBudget();
|
|
605
|
+
budget.reserve(rendered.width, rendered.height, `technology ${options.view} render`);
|
|
606
|
+
budget.reserveRasterOperation(`technology-render:${graph.revision}:${options.view}:${sha256Bytes(rendered.svg)}`, `technology ${options.view} rasterization`);
|
|
607
|
+
const png = await sharp(Buffer.from(rendered.svg, 'utf8'), {
|
|
608
|
+
limitInputPixels: RENDER_MAX_PIXELS,
|
|
609
|
+
})
|
|
610
|
+
.png({ compressionLevel: 9, adaptiveFiltering: false, palette: false })
|
|
611
|
+
.toBuffer();
|
|
612
|
+
options.signal?.throwIfAborted();
|
|
613
|
+
const json = `${canonicalJson({
|
|
614
|
+
...selection.payload,
|
|
615
|
+
nodes: retained,
|
|
616
|
+
edges,
|
|
617
|
+
omittedNodeCount: Math.max(0, selection.nodes.length - retained.length),
|
|
618
|
+
render: { width: rendered.width, height: rendered.height },
|
|
619
|
+
})}\n`;
|
|
620
|
+
const html = options.includeHtml === true ? htmlFor(selection.title, rendered.svg, json) : undefined;
|
|
621
|
+
return {
|
|
622
|
+
view: options.view,
|
|
623
|
+
graphRevision: graph.revision,
|
|
624
|
+
selectedIds: retained.map(({ id }) => id),
|
|
625
|
+
omittedNodeCount: Math.max(0, selection.nodes.length - retained.length),
|
|
626
|
+
sourceAccurate: selection.sourceAccurate,
|
|
627
|
+
generatedAnalysisLayout: !selection.sourceAccurate,
|
|
628
|
+
width: rendered.width,
|
|
629
|
+
height: rendered.height,
|
|
630
|
+
json,
|
|
631
|
+
svg: rendered.svg,
|
|
632
|
+
png,
|
|
633
|
+
...(html === undefined ? {} : { html }),
|
|
634
|
+
hashes: {
|
|
635
|
+
json: sha256Bytes(json),
|
|
636
|
+
svg: sha256Bytes(rendered.svg),
|
|
637
|
+
png: sha256Bytes(png),
|
|
638
|
+
...(html === undefined ? {} : { html: sha256Bytes(html) }),
|
|
639
|
+
},
|
|
640
|
+
};
|
|
641
|
+
}
|