@slicedearth/whoisleuth-cli 1.36.1 → 1.43.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/README.md +32 -2
- package/bin/whoisleuth.mjs +52 -24
- package/cli/archive-inspect.mjs +37 -2
- package/cli/arguments.mjs +493 -157
- package/cli/artifact-verify.mjs +91 -15
- package/cli/bulk-checkpoint.mjs +7 -11
- package/cli/bulk-command-runner.mjs +19 -2
- package/cli/case-pack.mjs +136 -0
- package/cli/ci-report.mjs +73 -0
- package/cli/collection-preflight.mjs +59 -0
- package/cli/command-reference.mjs +395 -0
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +49 -11
- package/cli/config-profile.mjs +180 -0
- package/cli/ct-event-intake.mjs +159 -0
- package/cli/discovery-scan-command-runner.mjs +15 -1
- package/cli/doctor.mjs +11 -5
- package/cli/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- package/cli/evidence-command-arguments.mjs +12 -1
- package/cli/evidence-command-runner.mjs +3 -2
- package/cli/evidence-signing.mjs +4 -4
- package/cli/external-observation-mapping.mjs +162 -0
- package/cli/fail-policy.mjs +64 -0
- package/cli/formatters/html.mjs +2 -2
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +7 -3
- package/cli/investigation-manifest.mjs +113 -0
- package/cli/investigation-plan.mjs +2 -2
- package/cli/investigation-run.mjs +112 -0
- package/cli/local-mmdb-review.mjs +76 -0
- package/cli/lookalike-calibration.mjs +121 -0
- package/cli/lookup-brief.mjs +116 -0
- package/cli/lookup-command-runner.mjs +12 -2
- package/cli/lookup-diff.mjs +17 -19
- package/cli/lookup-plan.mjs +3 -3
- package/cli/lookup-reconcile.mjs +141 -0
- package/cli/lookup-timeline.mjs +1 -1
- package/cli/mail-review.mjs +1 -1
- package/cli/network-command-runner.mjs +4 -1
- package/cli/offline-evidence-review.mjs +125 -10
- package/cli/open-asset-model-bridge.mjs +169 -0
- package/cli/registry-cohort.mjs +121 -0
- package/cli/registry-doctor.mjs +220 -0
- package/cli/registry-fixture-scaffold.mjs +73 -0
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +561 -280
- package/cli/saved-lookup.mjs +2 -2
- package/cli/sharing-review.mjs +202 -0
- package/cli/source-reliability.mjs +1 -1
- package/docs/cli-reference.md +1336 -0
- package/docs/cli.md +259 -940
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +7 -7
- package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
- package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
- package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
- package/frontend/src/lib/analysis/case-migration-model.js +6 -1
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +5 -1
- package/frontend/src/lib/analysis/case-record-operations.js +6 -1
- package/frontend/src/lib/analysis/case-report.js +573 -0
- package/frontend/src/lib/analysis/case-response-model.js +30 -1
- package/frontend/src/lib/analysis/case-response-packet.js +7 -7
- package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
- package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
- package/frontend/src/lib/analysis/external-findings-import.js +320 -0
- package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +487 -0
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +64 -0
- package/frontend/src/lib/analysis/lookup-response.js +2 -0
- package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
- package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
- package/frontend/src/lib/analysis/page-similarity.js +1 -1
- package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
- package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
- package/frontend/src/lib/analysis/scoring.js +9 -89
- package/frontend/src/lib/analysis/shortlist-model.js +8 -4
- package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
- package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
- package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
- package/frontend/src/lib/analysis/visualization-models.js +369 -0
- package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive.js +4 -2
- package/lib/abusech-auth.mjs +1 -1
- package/lib/analyst-taxonomy.mjs +40 -0
- package/lib/availability.mjs +2 -2
- package/lib/bounded-contract-normalizers.mjs +33 -0
- package/lib/browser-library-profile.mjs +15 -2
- package/lib/certificate-extension-profile.mjs +71 -0
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/dns-convergence-review.mjs +183 -0
- package/lib/dns-delegation-health.mjs +196 -3
- package/lib/domain-assurance.mjs +368 -0
- package/lib/domain-change-packet.mjs +133 -0
- package/lib/domain-change-review.mjs +307 -0
- package/lib/domain-control-flight-recorder.mjs +268 -0
- package/lib/domain-control-manifest.mjs +50 -7
- package/lib/domain-portfolio-review.mjs +151 -0
- package/lib/domain-posture.mjs +9 -9
- package/lib/encrypted-dns-contract.mjs +1 -1
- package/lib/evidence-export.mjs +1 -1
- package/lib/evidence-report-markdown.mjs +1 -1
- package/lib/evidence-report.mjs +1 -1
- package/lib/generated/cisa-kev-catalog.mjs +1668 -0
- package/lib/html-signals.mjs +5 -11
- package/lib/ip-prefix.mjs +34 -1
- package/lib/local-geoip-evidence.mjs +2 -2
- package/lib/lookup-response-contract.mjs +346 -0
- package/lib/nameserver-preflight-review.mjs +159 -0
- package/lib/observed-network-context.mjs +1 -1
- package/lib/opportunity-scoring.mjs +107 -0
- package/lib/page-fingerprints.mjs +1 -1
- package/lib/page-language-signals.mjs +124 -0
- package/lib/rdap-capabilities.mjs +6 -6
- package/lib/rdap-search-workbench.mjs +96 -6
- package/lib/registry-access.mjs +1 -1
- package/lib/registry-capability-catalogue.mjs +4 -4
- package/lib/registry-insights.mjs +6 -6
- package/lib/risk-scoring.mjs +247 -83
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +1 -1
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/urlhaus-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +5 -5
- package/lib/website-technology.mjs +25 -11
- package/lib/whois-chain.mjs +1 -1
- package/lib/zone-intent-review.mjs +534 -0
- package/package.json +5 -1
- package/third-party-notices.txt +911 -19
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY, } from 'd3-force';
|
|
2
|
+
import { boundedVisualizationId as boundedId, boundedVisualizationText as boundedText, } from "./visualization-bounds.js";
|
|
3
|
+
export const MAX_FORCE_GRAPH_NODES = 48;
|
|
4
|
+
export const MAX_FORCE_GRAPH_LINKS = 80;
|
|
5
|
+
export const FORCE_GRAPH_LINK_KINDS = [
|
|
6
|
+
'observed',
|
|
7
|
+
'partial',
|
|
8
|
+
'unknown',
|
|
9
|
+
'derived',
|
|
10
|
+
'summary',
|
|
11
|
+
];
|
|
12
|
+
function forceGraphLinkKind(value) {
|
|
13
|
+
const normalized = boundedId(value);
|
|
14
|
+
if (!normalized)
|
|
15
|
+
return 'observed';
|
|
16
|
+
return FORCE_GRAPH_LINK_KINDS.includes(normalized)
|
|
17
|
+
? normalized
|
|
18
|
+
: 'unknown';
|
|
19
|
+
}
|
|
20
|
+
const FORCE_GRAPH_GROUPS = {
|
|
21
|
+
address: { id: 'network', label: 'Network' },
|
|
22
|
+
certificate: { id: 'certificate', label: 'Certificates' },
|
|
23
|
+
domain: { id: 'hosts', label: 'Domains and hosts' },
|
|
24
|
+
hostname: { id: 'hosts', label: 'Domains and hosts' },
|
|
25
|
+
identity: { id: 'identity', label: 'Identity' },
|
|
26
|
+
issuer: { id: 'certificate', label: 'Certificates' },
|
|
27
|
+
key: { id: 'certificate', label: 'Certificates' },
|
|
28
|
+
network: { id: 'network', label: 'Network' },
|
|
29
|
+
observation: { id: 'observations', label: 'Source observations' },
|
|
30
|
+
origin: { id: 'identity', label: 'Identity' },
|
|
31
|
+
prefix: { id: 'network', label: 'Network' },
|
|
32
|
+
registrar: { id: 'registration', label: 'Registration' },
|
|
33
|
+
relationship: { id: 'relationships', label: 'Shared evidence' },
|
|
34
|
+
service: { id: 'services', label: 'Services' },
|
|
35
|
+
summary: { id: 'summary', label: 'Grouped evidence' },
|
|
36
|
+
target: { id: 'focus', label: 'Lookup target' },
|
|
37
|
+
technology: { id: 'technology', label: 'Technology' },
|
|
38
|
+
tracker: { id: 'identity', label: 'Identity' },
|
|
39
|
+
};
|
|
40
|
+
const FORCE_GRAPH_CLUSTER_INDEX = {
|
|
41
|
+
certificate: 0,
|
|
42
|
+
hosts: 1,
|
|
43
|
+
identity: 2,
|
|
44
|
+
network: 3,
|
|
45
|
+
observations: 4,
|
|
46
|
+
registration: 5,
|
|
47
|
+
relationships: 6,
|
|
48
|
+
services: 7,
|
|
49
|
+
summary: 6,
|
|
50
|
+
technology: 7,
|
|
51
|
+
};
|
|
52
|
+
function forceGraphClusterIndex(group) {
|
|
53
|
+
const assigned = FORCE_GRAPH_CLUSTER_INDEX[group];
|
|
54
|
+
if (assigned !== undefined)
|
|
55
|
+
return assigned;
|
|
56
|
+
let hash = 0;
|
|
57
|
+
for (const [index, character] of [...group].entries()) {
|
|
58
|
+
hash = (hash + (character.codePointAt(0) ?? 0) * (index + 1)) % 8;
|
|
59
|
+
}
|
|
60
|
+
return hash;
|
|
61
|
+
}
|
|
62
|
+
function forceGraphGroup(kind, rawGroup, rawGroupLabel) {
|
|
63
|
+
const requested = boundedId(rawGroup);
|
|
64
|
+
const requestedLabel = boundedText(rawGroupLabel, 40);
|
|
65
|
+
if (requested) {
|
|
66
|
+
return {
|
|
67
|
+
id: requested,
|
|
68
|
+
label: requestedLabel || requested.replaceAll('-', ' ').replace(/\b\w/gu, (character) => character.toUpperCase()),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return FORCE_GRAPH_GROUPS[kind] ?? {
|
|
72
|
+
id: kind || 'evidence',
|
|
73
|
+
label: (kind || 'evidence').replaceAll('-', ' ').replace(/\b\w/gu, (character) => character.toUpperCase()),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function wrapForceGraphLabel(value, maximumLineLength = 20) {
|
|
77
|
+
const lines = [];
|
|
78
|
+
for (const word of value.split(/\s+/u).filter(Boolean)) {
|
|
79
|
+
const pieces = [];
|
|
80
|
+
let remaining = word;
|
|
81
|
+
while (remaining.length > maximumLineLength) {
|
|
82
|
+
let splitAt = maximumLineLength;
|
|
83
|
+
for (let index = maximumLineLength; index >= Math.floor(maximumLineLength * 0.55); index -= 1) {
|
|
84
|
+
if (/[./:_-]/u.test(remaining[index - 1] ?? '')) {
|
|
85
|
+
splitAt = index;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
pieces.push(remaining.slice(0, splitAt));
|
|
90
|
+
remaining = remaining.slice(splitAt);
|
|
91
|
+
}
|
|
92
|
+
if (remaining)
|
|
93
|
+
pieces.push(remaining);
|
|
94
|
+
for (const [pieceIndex, piece] of pieces.entries()) {
|
|
95
|
+
const current = lines.at(-1);
|
|
96
|
+
if (pieceIndex === 0 && current && current.length + piece.length + 1 <= maximumLineLength) {
|
|
97
|
+
lines[lines.length - 1] = `${current} ${piece}`;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
lines.push(piece);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return lines.length ? lines : [value];
|
|
105
|
+
}
|
|
106
|
+
function forceGraphClusterCenters(groups, width, height, hasFocus) {
|
|
107
|
+
const centerX = width / 2;
|
|
108
|
+
const centerY = height / 2;
|
|
109
|
+
const centers = new Map();
|
|
110
|
+
if (!groups.length)
|
|
111
|
+
return centers;
|
|
112
|
+
if (!hasFocus && groups.length === 1) {
|
|
113
|
+
centers.set(groups[0] ?? '', { x: centerX, y: centerY });
|
|
114
|
+
return centers;
|
|
115
|
+
}
|
|
116
|
+
if (!hasFocus && groups.length === 2) {
|
|
117
|
+
centers.set(groups[0] ?? '', { x: width * 0.29, y: centerY });
|
|
118
|
+
centers.set(groups[1] ?? '', { x: width * 0.71, y: centerY });
|
|
119
|
+
return centers;
|
|
120
|
+
}
|
|
121
|
+
if (hasFocus && groups.length === 1) {
|
|
122
|
+
centers.set(groups[0] ?? '', { x: width * 0.72, y: centerY });
|
|
123
|
+
return centers;
|
|
124
|
+
}
|
|
125
|
+
if (hasFocus && groups.length === 2) {
|
|
126
|
+
centers.set(groups[0] ?? '', { x: width * 0.25, y: centerY });
|
|
127
|
+
centers.set(groups[1] ?? '', { x: width * 0.75, y: centerY });
|
|
128
|
+
return centers;
|
|
129
|
+
}
|
|
130
|
+
const radiusX = hasFocus ? width * 0.33 : width * 0.27;
|
|
131
|
+
const radiusY = hasFocus ? Math.max(150, height * 0.36) : Math.max(125, height * 0.3);
|
|
132
|
+
for (const [index, group] of groups.entries()) {
|
|
133
|
+
const angle = -Math.PI / 2 + index * Math.PI * 2 / groups.length;
|
|
134
|
+
centers.set(group, {
|
|
135
|
+
x: centerX + Math.cos(angle) * radiusX,
|
|
136
|
+
y: centerY + Math.sin(angle) * radiusY,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return centers;
|
|
140
|
+
}
|
|
141
|
+
function forceGraphNodeBounds(node) {
|
|
142
|
+
if (node.kind === 'target') {
|
|
143
|
+
const halfWidth = (node.labelWidth + 20) / 2;
|
|
144
|
+
const halfHeight = (node.labelLines.length * 13 + 17) / 2;
|
|
145
|
+
return { halfWidth, above: halfHeight, below: halfHeight };
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
halfWidth: Math.max(20, node.labelWidth / 2),
|
|
149
|
+
above: 22,
|
|
150
|
+
below: 33 + node.labelLines.length * 13,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
function resolveForceGraphLabelCollisions(nodes, width, height, focusNodeId) {
|
|
154
|
+
const margin = 7;
|
|
155
|
+
for (let iteration = 0; iteration < 120; iteration += 1) {
|
|
156
|
+
let moved = false;
|
|
157
|
+
for (let leftIndex = 0; leftIndex < nodes.length; leftIndex += 1) {
|
|
158
|
+
const left = nodes[leftIndex];
|
|
159
|
+
if (!left)
|
|
160
|
+
continue;
|
|
161
|
+
const leftBounds = forceGraphNodeBounds(left);
|
|
162
|
+
for (let rightIndex = leftIndex + 1; rightIndex < nodes.length; rightIndex += 1) {
|
|
163
|
+
const right = nodes[rightIndex];
|
|
164
|
+
if (!right)
|
|
165
|
+
continue;
|
|
166
|
+
const rightBounds = forceGraphNodeBounds(right);
|
|
167
|
+
const overlapX = leftBounds.halfWidth + rightBounds.halfWidth + margin - Math.abs(left.x - right.x);
|
|
168
|
+
const overlapY = Math.min(left.y + leftBounds.below, right.y + rightBounds.below) - Math.max(left.y - leftBounds.above, right.y - rightBounds.above) + margin;
|
|
169
|
+
if (overlapX <= 0 || overlapY <= 0)
|
|
170
|
+
continue;
|
|
171
|
+
moved = true;
|
|
172
|
+
const leftFixed = left.id === focusNodeId;
|
|
173
|
+
const rightFixed = right.id === focusNodeId;
|
|
174
|
+
if (overlapX / (leftBounds.halfWidth + rightBounds.halfWidth) < overlapY / Math.max(leftBounds.above + leftBounds.below, rightBounds.above + rightBounds.below)) {
|
|
175
|
+
const direction = left.x <= right.x ? -1 : 1;
|
|
176
|
+
const shift = overlapX / (leftFixed || rightFixed ? 1 : 2) + 0.5;
|
|
177
|
+
if (!leftFixed)
|
|
178
|
+
left.x += direction * shift;
|
|
179
|
+
if (!rightFixed)
|
|
180
|
+
right.x -= direction * shift;
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
const direction = left.y <= right.y ? -1 : 1;
|
|
184
|
+
const shift = overlapY / (leftFixed || rightFixed ? 1 : 2) + 0.5;
|
|
185
|
+
if (!leftFixed)
|
|
186
|
+
left.y += direction * shift;
|
|
187
|
+
if (!rightFixed)
|
|
188
|
+
right.y -= direction * shift;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
for (const node of nodes) {
|
|
193
|
+
const bounds = forceGraphNodeBounds(node);
|
|
194
|
+
node.x = Math.max(bounds.halfWidth + margin, Math.min(width - bounds.halfWidth - margin, node.x));
|
|
195
|
+
node.y = Math.max(bounds.above + margin, Math.min(height - bounds.below - margin, node.y));
|
|
196
|
+
}
|
|
197
|
+
if (!moved)
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
export function projectBoundedForceGraph(rawNodes, rawLinks, options = {}) {
|
|
202
|
+
const rawNodeCount = Array.isArray(rawNodes) ? rawNodes.length : 0;
|
|
203
|
+
const rawLinkCount = Array.isArray(rawLinks) ? rawLinks.length : 0;
|
|
204
|
+
const seen = new Set();
|
|
205
|
+
const candidates = (Array.isArray(rawNodes) ? rawNodes : [])
|
|
206
|
+
.slice(0, MAX_FORCE_GRAPH_NODES * 2)
|
|
207
|
+
.map((node) => {
|
|
208
|
+
const id = boundedText(node?.id, 80);
|
|
209
|
+
const label = boundedText(node?.label, 64);
|
|
210
|
+
if (!id || !label || seen.has(id))
|
|
211
|
+
return null;
|
|
212
|
+
seen.add(id);
|
|
213
|
+
const kind = boundedId(node?.kind) || 'evidence';
|
|
214
|
+
const group = forceGraphGroup(kind, node?.group, node?.groupLabel);
|
|
215
|
+
const labelLines = wrapForceGraphLabel(label);
|
|
216
|
+
const labelWidth = Math.max(54, Math.min(142, Math.max(...labelLines.map((line) => line.length)) * 6.2 + 16));
|
|
217
|
+
return {
|
|
218
|
+
id,
|
|
219
|
+
label,
|
|
220
|
+
labelLines,
|
|
221
|
+
labelWidth,
|
|
222
|
+
kind,
|
|
223
|
+
detail: boundedText(node?.detail, 100),
|
|
224
|
+
group: group.id,
|
|
225
|
+
groupLabel: group.label,
|
|
226
|
+
clusterIndex: 0,
|
|
227
|
+
collisionRadius: Math.max(38, labelWidth / 2 + 8, 30 + labelLines.length * 6),
|
|
228
|
+
x: 0,
|
|
229
|
+
y: 0,
|
|
230
|
+
};
|
|
231
|
+
})
|
|
232
|
+
.filter((node) => Boolean(node))
|
|
233
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
234
|
+
const nodes = candidates.slice(0, MAX_FORCE_GRAPH_NODES);
|
|
235
|
+
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
236
|
+
const seenLinks = new Set();
|
|
237
|
+
const links = (Array.isArray(rawLinks) ? rawLinks : [])
|
|
238
|
+
.slice(0, MAX_FORCE_GRAPH_LINKS * 2)
|
|
239
|
+
.map((link, index) => ({
|
|
240
|
+
id: boundedText(link?.id, 80) || `link-${index}`,
|
|
241
|
+
source: boundedText(link?.source, 80),
|
|
242
|
+
target: boundedText(link?.target, 80),
|
|
243
|
+
kind: forceGraphLinkKind(link?.kind),
|
|
244
|
+
detail: boundedText(link?.detail, 100),
|
|
245
|
+
}))
|
|
246
|
+
.filter((link) => {
|
|
247
|
+
if (link.source === link.target
|
|
248
|
+
|| !nodeIds.has(link.source)
|
|
249
|
+
|| !nodeIds.has(link.target)
|
|
250
|
+
|| seenLinks.has(link.id)) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
seenLinks.add(link.id);
|
|
254
|
+
return true;
|
|
255
|
+
})
|
|
256
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
257
|
+
.slice(0, MAX_FORCE_GRAPH_LINKS);
|
|
258
|
+
const requestedFocusId = boundedText(options.focusNodeId, 80);
|
|
259
|
+
const focusNode = nodes.find((node) => node.id === requestedFocusId)
|
|
260
|
+
?? nodes.find((node) => node.kind === 'target');
|
|
261
|
+
const groupLabels = new Map();
|
|
262
|
+
for (const node of nodes) {
|
|
263
|
+
if (node.id !== focusNode?.id && !groupLabels.has(node.group))
|
|
264
|
+
groupLabels.set(node.group, node.groupLabel);
|
|
265
|
+
}
|
|
266
|
+
const groupIds = [...groupLabels.keys()].sort((left, right) => {
|
|
267
|
+
const leftLabel = groupLabels.get(left) ?? left;
|
|
268
|
+
const rightLabel = groupLabels.get(right) ?? right;
|
|
269
|
+
return leftLabel.localeCompare(rightLabel) || left.localeCompare(right);
|
|
270
|
+
});
|
|
271
|
+
const height = Math.max(500, Math.min(700, 450 + Math.max(0, nodes.length - 14) * 16));
|
|
272
|
+
const width = 900;
|
|
273
|
+
const centerX = width / 2;
|
|
274
|
+
const centerY = height / 2;
|
|
275
|
+
const clusterCenters = forceGraphClusterCenters(groupIds, width, height, Boolean(focusNode));
|
|
276
|
+
const clusterIndex = new Map(groupIds.map((group) => [group, forceGraphClusterIndex(group)]));
|
|
277
|
+
for (const node of nodes)
|
|
278
|
+
node.clusterIndex = clusterIndex.get(node.group) ?? 0;
|
|
279
|
+
if (nodes.length) {
|
|
280
|
+
if (focusNode) {
|
|
281
|
+
focusNode.fx = centerX;
|
|
282
|
+
focusNode.fy = centerY;
|
|
283
|
+
}
|
|
284
|
+
const simulation = forceSimulation(nodes)
|
|
285
|
+
.force('link', forceLink(links)
|
|
286
|
+
.id((node) => node.id)
|
|
287
|
+
.distance((link) => link.kind === 'derived' ? 145 : 125)
|
|
288
|
+
.strength(0.22))
|
|
289
|
+
.force('charge', forceManyBody().strength(-330))
|
|
290
|
+
.force('center', forceCenter(centerX, centerY))
|
|
291
|
+
.force('cluster-x', forceX((node) => node.id === focusNode?.id ? centerX : clusterCenters.get(node.group)?.x ?? centerX).strength(0.3))
|
|
292
|
+
.force('cluster-y', forceY((node) => node.id === focusNode?.id ? centerY : clusterCenters.get(node.group)?.y ?? centerY).strength(0.3))
|
|
293
|
+
.force('collide', forceCollide()
|
|
294
|
+
.radius((node) => node.kind === 'target' ? Math.max(66, node.collisionRadius) : node.collisionRadius)
|
|
295
|
+
.strength(1)
|
|
296
|
+
.iterations(5))
|
|
297
|
+
.stop();
|
|
298
|
+
simulation.tick(360);
|
|
299
|
+
for (const node of nodes) {
|
|
300
|
+
const horizontalInset = Math.max(38, Math.min(82, node.collisionRadius));
|
|
301
|
+
const topInset = node.kind === 'target' ? 28 : 34;
|
|
302
|
+
const bottomInset = Math.max(52, 34 + node.labelLines.length * 13);
|
|
303
|
+
node.x = Math.max(horizontalInset, Math.min(width - horizontalInset, Number(node.x) || centerX));
|
|
304
|
+
node.y = Math.max(topInset, Math.min(height - bottomInset, Number(node.y) || centerY));
|
|
305
|
+
}
|
|
306
|
+
resolveForceGraphLabelCollisions(nodes, width, height, focusNode?.id);
|
|
307
|
+
}
|
|
308
|
+
const projectedLinks = links.flatMap((link) => {
|
|
309
|
+
const source = typeof link.source === 'string' ? nodes.find((node) => node.id === link.source) : link.source;
|
|
310
|
+
const target = typeof link.target === 'string' ? nodes.find((node) => node.id === link.target) : link.target;
|
|
311
|
+
return source && target ? [{
|
|
312
|
+
id: link.id,
|
|
313
|
+
sourceId: source.id,
|
|
314
|
+
targetId: target.id,
|
|
315
|
+
sourceX: source.x,
|
|
316
|
+
sourceY: source.y,
|
|
317
|
+
targetX: target.x,
|
|
318
|
+
targetY: target.y,
|
|
319
|
+
kind: link.kind,
|
|
320
|
+
detail: link.detail,
|
|
321
|
+
}] : [];
|
|
322
|
+
});
|
|
323
|
+
const omittedNodeInputs = Math.max(0, rawNodeCount - nodes.length);
|
|
324
|
+
const omittedLinkInputs = Math.max(0, rawLinkCount - projectedLinks.length);
|
|
325
|
+
return {
|
|
326
|
+
width,
|
|
327
|
+
height,
|
|
328
|
+
nodes: nodes.map(({ id, label, labelLines, labelWidth, kind, detail, group, groupLabel, clusterIndex, x, y }) => ({
|
|
329
|
+
id,
|
|
330
|
+
label,
|
|
331
|
+
labelLines,
|
|
332
|
+
labelWidth,
|
|
333
|
+
kind,
|
|
334
|
+
detail,
|
|
335
|
+
group,
|
|
336
|
+
groupLabel,
|
|
337
|
+
clusterIndex,
|
|
338
|
+
x,
|
|
339
|
+
y,
|
|
340
|
+
})),
|
|
341
|
+
links: projectedLinks,
|
|
342
|
+
clusters: groupIds.map((group) => ({
|
|
343
|
+
id: group,
|
|
344
|
+
label: groupLabels.get(group) ?? group,
|
|
345
|
+
count: nodes.filter((node) => node.id !== focusNode?.id && node.group === group).length,
|
|
346
|
+
index: clusterIndex.get(group) ?? 0,
|
|
347
|
+
})),
|
|
348
|
+
omittedNodeInputs,
|
|
349
|
+
omittedLinkInputs,
|
|
350
|
+
truncated: omittedNodeInputs > 0 || omittedLinkInputs > 0,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { scaleBand } from 'd3-scale';
|
|
2
|
+
import { boundedVisualizationId as boundedId, boundedVisualizationText as boundedText, } from "./visualization-bounds.js";
|
|
3
|
+
export const MAX_VISUAL_MATRIX_ROWS = 24;
|
|
4
|
+
export const MAX_VISUAL_MATRIX_COLUMNS = 6;
|
|
5
|
+
function normalizedMatrixState(value) {
|
|
6
|
+
const state = boundedText(value, 30).toLowerCase();
|
|
7
|
+
if (state === 'equal' || state === 'equivalent' || state === 'same')
|
|
8
|
+
return 'equal';
|
|
9
|
+
if (state === 'different')
|
|
10
|
+
return 'different';
|
|
11
|
+
if (state === 'conflict' || state === 'conflicting')
|
|
12
|
+
return 'conflict';
|
|
13
|
+
if (state === 'observed' || state === 'complete' || state === 'success')
|
|
14
|
+
return 'observed';
|
|
15
|
+
if (state === 'partial' || state === 'inconclusive' || state === 'rate_limited' || state === 'missing')
|
|
16
|
+
return 'partial';
|
|
17
|
+
if (state === 'unavailable' || state === 'error' || state === 'failed')
|
|
18
|
+
return 'unavailable';
|
|
19
|
+
if (state === 'not_collected' || state === 'skipped' || state === 'disabled' || state === 'not_recorded') {
|
|
20
|
+
return 'not_collected';
|
|
21
|
+
}
|
|
22
|
+
return 'unknown';
|
|
23
|
+
}
|
|
24
|
+
export function projectEvidenceMatrix(rawColumns, rawRows) {
|
|
25
|
+
const columns = [...new Set((Array.isArray(rawColumns) ? rawColumns : [])
|
|
26
|
+
.map((column) => boundedText(column, 40))
|
|
27
|
+
.filter(Boolean))]
|
|
28
|
+
.slice(0, MAX_VISUAL_MATRIX_COLUMNS);
|
|
29
|
+
const columnSet = new Set(columns);
|
|
30
|
+
const seenRows = new Set();
|
|
31
|
+
const candidates = (Array.isArray(rawRows) ? rawRows : [])
|
|
32
|
+
.map((row, index) => {
|
|
33
|
+
const id = boundedId(row?.id) || `row-${index}`;
|
|
34
|
+
const label = boundedText(row?.label, 56);
|
|
35
|
+
if (!label || seenRows.has(id))
|
|
36
|
+
return null;
|
|
37
|
+
seenRows.add(id);
|
|
38
|
+
const rowCells = (Array.isArray(row.cells) ? row.cells : []);
|
|
39
|
+
const byColumn = new Map(rowCells
|
|
40
|
+
.map((cell) => {
|
|
41
|
+
const column = boundedText(cell?.column, 40);
|
|
42
|
+
return [column, {
|
|
43
|
+
state: normalizedMatrixState(cell?.state),
|
|
44
|
+
detail: boundedText(cell?.detail, 120),
|
|
45
|
+
}];
|
|
46
|
+
})
|
|
47
|
+
.filter(([column]) => columnSet.has(column)));
|
|
48
|
+
return {
|
|
49
|
+
id,
|
|
50
|
+
label,
|
|
51
|
+
cells: columns.map((column) => ({
|
|
52
|
+
column,
|
|
53
|
+
state: byColumn.get(column)?.state ?? 'not_collected',
|
|
54
|
+
detail: byColumn.get(column)?.detail ?? '',
|
|
55
|
+
})),
|
|
56
|
+
};
|
|
57
|
+
})
|
|
58
|
+
.filter((row) => Boolean(row));
|
|
59
|
+
const rows = candidates.slice(0, MAX_VISUAL_MATRIX_ROWS);
|
|
60
|
+
const width = 900;
|
|
61
|
+
const top = 54;
|
|
62
|
+
const left = 210;
|
|
63
|
+
const rowHeight = 30;
|
|
64
|
+
const x = scaleBand().domain(columns).range([left, 870]).padding(0.12);
|
|
65
|
+
const y = scaleBand().domain(rows.map((row) => row.id))
|
|
66
|
+
.range([top, top + Math.max(1, rows.length) * rowHeight])
|
|
67
|
+
.padding(0.12);
|
|
68
|
+
return {
|
|
69
|
+
width,
|
|
70
|
+
height: Math.max(104, top + rows.length * rowHeight + 22),
|
|
71
|
+
columns: columns.map((column) => ({
|
|
72
|
+
label: column,
|
|
73
|
+
x: x(column) ?? left,
|
|
74
|
+
width: x.bandwidth(),
|
|
75
|
+
})),
|
|
76
|
+
rows: rows.map((row) => ({
|
|
77
|
+
...row,
|
|
78
|
+
y: y(row.id) ?? top,
|
|
79
|
+
height: y.bandwidth(),
|
|
80
|
+
cells: row.cells.map((cell) => ({
|
|
81
|
+
...cell,
|
|
82
|
+
x: x(cell.column) ?? left,
|
|
83
|
+
width: x.bandwidth(),
|
|
84
|
+
})),
|
|
85
|
+
})),
|
|
86
|
+
truncated: candidates.length > rows.length || rawColumns.length > columns.length,
|
|
87
|
+
};
|
|
88
|
+
}
|