@slicedearth/whoisleuth-cli 1.47.4 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -177
- package/SECURITY.md +6 -3
- package/bin/whoisleuth.mjs +2 -1
- package/cli/archive-inspect.mjs +28 -10
- package/cli/arguments.mjs +204 -174
- package/cli/artifact-structure.mjs +837 -163
- package/cli/artifact-verify.mjs +90 -61
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +33 -20
- package/cli/bulk.mjs +9 -2
- package/cli/case-pack.mjs +180 -576
- package/cli/ci-report.mjs +6 -1
- package/cli/collection-preflight.mjs +2 -2
- package/cli/command-catalogue.mjs +12 -3
- package/cli/command-reference.mjs +910 -92
- package/cli/completion.mjs +504 -226
- package/cli/config-profile.mjs +13 -3
- package/cli/discover.mjs +7 -1
- package/cli/discovery-observation-snapshot.mjs +24 -28
- package/cli/discovery-scan-command-runner.mjs +9 -9
- package/cli/discovery-scan.mjs +17 -3
- package/cli/discovery-snapshot.mjs +15 -7
- package/cli/domain-control-monitor.mjs +203 -30
- package/cli/domain-control-observations.mjs +15 -21
- package/cli/errors.mjs +8 -3
- package/cli/evidence-signing.mjs +10 -20
- package/cli/external-observation-mapping.mjs +3 -3
- package/cli/formatters/json.mjs +20 -32
- package/cli/formatters/terminal.mjs +583 -22
- package/cli/input.mjs +7 -2
- package/cli/interactive-launcher.mjs +226 -0
- package/cli/interchange-report.mjs +19 -19
- package/cli/investigation-manifest.mjs +1 -1
- package/cli/investigation-plan.mjs +167 -16
- package/cli/investigation-run.mjs +9 -1
- package/cli/lookalike-calibration.mjs +3 -1
- package/cli/lookup-brief.mjs +13 -8
- package/cli/lookup-browser.mjs +844 -0
- package/cli/lookup-command-runner.mjs +94 -31
- package/cli/lookup-diff.mjs +2 -2
- package/cli/lookup-plan.mjs +1 -0
- package/cli/mail-review.mjs +206 -94
- package/cli/manual.mjs +5 -5
- package/cli/offline-evidence-review.mjs +18 -7
- package/cli/open-asset-model-bridge.mjs +1 -1
- package/cli/output-file.mjs +70 -14
- package/cli/page-compare.mjs +11 -6
- package/cli/progress-events.mjs +2 -1
- package/cli/progress.mjs +3 -3
- package/cli/registry-cohort.mjs +9 -51
- package/cli/registry-doctor.mjs +67 -28
- package/cli/retained-artifact-diff.mjs +7 -8
- package/cli/risk-calibration.mjs +85 -38
- package/cli/runner.mjs +189 -75
- package/cli/saved-lookup.mjs +424 -57
- package/cli/sharing-review.mjs +6 -1
- package/cli/source-reliability.mjs +75 -36
- package/cli/terminal-presentation.mjs +43 -18
- package/docs/cli-reference.md +125 -1708
- package/docs/cli.md +86 -254
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
- package/frontend/src/lib/analysis/domain-control-records.js +3 -88
- package/lib/availability.mjs +58 -40
- package/lib/bounded-contract-normalizers.mjs +2 -4
- package/lib/bounded-file.mjs +78 -4
- package/lib/bounded-json.mjs +51 -13
- package/lib/browser-library-profile.mjs +1 -1
- package/lib/classify.mjs +56 -5
- package/lib/client-behavior-profile.mjs +1 -1
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/cryptographic-assurance.mjs +35 -17
- package/lib/ct-query.mjs +3 -3
- package/lib/ct-search.mjs +37 -21
- package/lib/distributed-operation-budget.mjs +4 -2
- package/lib/dns-delegation-health.mjs +9 -6
- package/lib/dns-intelligence.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +2 -3
- package/lib/dnssec-evidence-validation.mjs +13 -7
- package/lib/domain-assurance.mjs +12 -12
- package/lib/domain-change-packet.mjs +1 -1
- package/lib/domain-control-flight-recorder.mjs +442 -78
- package/lib/domain-control-manifest.mjs +314 -59
- package/lib/domain-posture-analysis.mjs +5 -2
- package/lib/encrypted-dns-contract.mjs +2 -3
- package/lib/evidence-export.mjs +518 -36
- package/lib/external-intelligence-risk.mjs +26 -10
- package/lib/feature-policy.mjs +1 -1
- package/lib/homepage-metadata-contract.mjs +290 -0
- package/lib/hostname.mjs +1 -1
- package/lib/html-signals.mjs +89 -5
- package/lib/http-intelligence.mjs +331 -2
- package/lib/interchange-fidelity-registry.mjs +28 -26
- package/lib/lookup-cache.mjs +6 -4
- package/lib/lookup.mjs +25 -2
- package/lib/observed-network-context.mjs +1 -1
- package/lib/page-fingerprints.mjs +2 -2
- package/lib/page-role-profile.mjs +1 -1
- package/lib/portable-generator.mjs +8 -0
- package/lib/provider-policy-admission.mjs +3 -1
- package/lib/rdap-bootstrap.mjs +76 -16
- package/lib/rdap-client.mjs +20 -12
- package/lib/rdap-normalization.mjs +306 -75
- package/lib/rdap-transport.mjs +2 -2
- package/lib/rdap-validation.mjs +65 -14
- package/lib/rdap.mjs +2 -2
- package/lib/registrable-domain.mjs +36 -0
- package/lib/registry-insights.mjs +7 -0
- package/lib/response-policy.mjs +27 -4
- package/lib/risk-calibration-summary.mjs +35 -33
- package/lib/risk-scoring.mjs +4 -3
- package/lib/safe-fetch.mjs +62 -40
- package/lib/security-txt.mjs +1 -1
- package/lib/smtp-transport-review.mjs +8 -5
- package/lib/sslbl-intelligence.mjs +6 -8
- package/lib/static-html-analysis.mjs +301 -4
- package/lib/structured-data-identity.mjs +51 -19
- package/lib/threat-intelligence-fixtures.mjs +2 -4
- package/lib/threat-intelligence-manifests.mjs +2 -7
- package/lib/threat-intelligence-runtime.mjs +3 -5
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +7 -3
- package/lib/tls-intelligence.mjs +4 -4
- package/lib/urlhaus-intelligence.mjs +4 -2
- package/lib/urlscan-intelligence.mjs +6 -5
- package/lib/website-security-posture.mjs +1 -1
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-authority.mjs +43 -8
- package/lib/whois-chain.mjs +25 -3
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-transport.mjs +12 -3
- package/lib/zone-intent-review.mjs +1 -1
- package/package.json +12 -4
- package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
- package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
- package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
- package/packages/cases/case-migration-model.mjs +476 -0
- package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
- package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
- package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
- package/packages/cases/case-record-model.mjs +9 -0
- package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
- package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
- package/packages/cases/case-response-model.mjs +1604 -0
- package/packages/cases/case-response-packet.mjs +1312 -0
- package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
- package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
- package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
- package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
- package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
- package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
- package/packages/comparison/favicon-similarity.mjs +78 -0
- package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
- package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
- package/packages/contracts/analyst-review-state-contract.mjs +61 -0
- package/packages/contracts/analyst-review-state.mjs +50 -0
- package/packages/contracts/case-portability.mjs +1310 -0
- package/packages/contracts/cli-command-catalogue.mjs +2 -0
- package/packages/contracts/cli-lookup.mjs +394 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
- package/packages/contracts/domain-control-manifest.mjs +1027 -0
- package/packages/contracts/domain-control-monitor.mjs +310 -0
- package/packages/contracts/domain-control-review.mjs +828 -0
- package/packages/contracts/domain-name.mjs +4 -0
- package/packages/contracts/external-observation-interchange.mjs +224 -0
- package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
- package/packages/contracts/investigation-portability.mjs +331 -0
- package/packages/contracts/offline-comparison.mjs +160 -0
- package/packages/contracts/risk-calibration.mjs +1288 -0
- package/packages/contracts/schema-compatibility.mjs +71 -0
- package/packages/contracts/schema-lifecycle.mjs +1702 -0
- package/packages/contracts/sslbl-snapshot.mjs +17 -0
- package/packages/contracts/workspace-portability.mjs +1347 -0
- package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
- package/packages/evidence/decision-fact.mjs +735 -0
- package/packages/evidence/domain-control-runtime.mjs +547 -0
- package/packages/evidence/domain-name.mjs +41 -0
- package/packages/evidence/observation.mjs +384 -0
- package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
- package/packages/interchange/retained-text.mjs +8 -0
- package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
- package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
- package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
- package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
- package/packages/investigation/bulk-review-policy.mjs +1 -0
- package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
- package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
- package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
- package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
- package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
- package/packages/investigation/lookup-investigation-brief.mjs +172 -0
- package/packages/monitoring/analyst-review-state.mjs +680 -0
- package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +70 -47
- package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
- package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
- package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
- package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
- package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
- package/packages/workspace/hostile-input.mjs +172 -0
- package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
- package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
- package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
- package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
- package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
- package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
- package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
- package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
- package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
- package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
- package/third-party-notices.txt +14 -14
- package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
- package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
- package/frontend/src/lib/analysis/case-migration-model.js +0 -305
- package/frontend/src/lib/analysis/case-record-model.js +0 -9
- package/frontend/src/lib/analysis/case-response-model.js +0 -728
- package/frontend/src/lib/analysis/case-response-packet.js +0 -583
- package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
- package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
- package/frontend/src/lib/analysis/lookup-response.js +0 -2
- package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
- package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
- package/frontend/src/lib/analysis/scoring.js +0 -55
- package/frontend/src/lib/analysis/utils.js +0 -255
- package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
- package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
- package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
- package/frontend/src/lib/analysis/visualization-models.js +0 -338
- package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
- package/lib/bounded-json-response.mjs +0 -170
- package/lib/lookup-response-contract.mjs +0 -1034
- package/lib/observation.mjs +0 -115
- /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
- /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
- /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
- /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
|
@@ -1,255 +0,0 @@
|
|
|
1
|
-
// Browser-safe analysis helpers shared by the Svelte tools.
|
|
2
|
-
import { serializeCsvCell, serializeCsvRows } from "../../../../lib/csv.mjs";
|
|
3
|
-
import { hammingDistanceHex, isInformativePerceptualHash, isPerceptualHash, } from "../../../../lib/perceptual-hash-comparison.mjs";
|
|
4
|
-
// Deliberately conservative (no +tags, no comments, no quoted local parts) -
|
|
5
|
-
// this only gates whether a WHOIS/RDAP-sourced string is safe to drop into a
|
|
6
|
-
// mailto: URI as the recipient, not a general email validator. mailto:
|
|
7
|
-
// treats a comma as an additional-recipient separator (RFC 6068), so a
|
|
8
|
-
// registrant/abuse-contact field containing e.g. "person@example.test,cc@hostile.example"
|
|
9
|
-
// would silently add a second recipient to the outreach/abuse draft the user
|
|
10
|
-
// opens - rejecting anything outside a single plain address closes that off.
|
|
11
|
-
const SIMPLE_EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
|
|
12
|
-
const MAX_ENTITY_DISPLAY_LENGTH = 300;
|
|
13
|
-
export function isValidEmailAddress(value) {
|
|
14
|
-
return typeof value === 'string' && SIMPLE_EMAIL_RE.test(value.trim());
|
|
15
|
-
}
|
|
16
|
-
// RDAP and compact availability results represent a registrar as an entity,
|
|
17
|
-
// while some WHOIS paths retain the historical string field. Normalize both
|
|
18
|
-
// forms before they reach UI summaries or browser-local evidence; blindly
|
|
19
|
-
// calling String() on an entity would persist "[object Object]" and hide real
|
|
20
|
-
// registrar changes. The backend already bounds these values, but this client
|
|
21
|
-
// boundary revalidates the API response before retaining it.
|
|
22
|
-
function plainRecord(value) {
|
|
23
|
-
return value && typeof value === 'object' && !Array.isArray(value)
|
|
24
|
-
? value
|
|
25
|
-
: null;
|
|
26
|
-
}
|
|
27
|
-
export function entityDisplayName(value) {
|
|
28
|
-
let candidate = value;
|
|
29
|
-
const record = plainRecord(value);
|
|
30
|
-
if (record) {
|
|
31
|
-
candidate = record.name || record.org || record.handle;
|
|
32
|
-
}
|
|
33
|
-
if (typeof candidate !== 'string' && typeof candidate !== 'number')
|
|
34
|
-
return null;
|
|
35
|
-
const normalized = String(candidate)
|
|
36
|
-
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
37
|
-
.replace(/\s+/g, ' ')
|
|
38
|
-
.trim()
|
|
39
|
-
.slice(0, MAX_ENTITY_DISPLAY_LENGTH);
|
|
40
|
-
return normalized || null;
|
|
41
|
-
}
|
|
42
|
-
export { hammingDistanceHex };
|
|
43
|
-
// A dHash carries usable structure only when its set-bit count is away from
|
|
44
|
-
// the degenerate extremes. The shared browser-safe contract applies the same
|
|
45
|
-
// threshold during generation and comparison, including for older retained
|
|
46
|
-
// profile values.
|
|
47
|
-
export function isInformativeFaviconHash(hex) {
|
|
48
|
-
return isInformativePerceptualHash(hex);
|
|
49
|
-
}
|
|
50
|
-
function faviconRecord(value) {
|
|
51
|
-
const record = plainRecord(value);
|
|
52
|
-
if (!record || typeof record.domain !== 'string')
|
|
53
|
-
return null;
|
|
54
|
-
const faviconHash = typeof record.faviconHash === 'string' && record.faviconHash
|
|
55
|
-
? record.faviconHash
|
|
56
|
-
: null;
|
|
57
|
-
const faviconPHash = isPerceptualHash(record.faviconPHash)
|
|
58
|
-
? record.faviconPHash
|
|
59
|
-
: null;
|
|
60
|
-
return faviconHash || faviconPHash
|
|
61
|
-
? { domain: record.domain, faviconHash, faviconPHash }
|
|
62
|
-
: null;
|
|
63
|
-
}
|
|
64
|
-
export function groupBySimilarFavicon(records, maxDistance) {
|
|
65
|
-
const items = Array.isArray(records)
|
|
66
|
-
? records.map(faviconRecord).filter((record) => record !== null)
|
|
67
|
-
: [];
|
|
68
|
-
const parent = items.map((_, i) => i);
|
|
69
|
-
const find = (x) => {
|
|
70
|
-
let root = x;
|
|
71
|
-
while ((parent[root] ?? root) !== root)
|
|
72
|
-
root = parent[root] ?? root;
|
|
73
|
-
while ((parent[x] ?? x) !== root) {
|
|
74
|
-
const next = parent[x] ?? x;
|
|
75
|
-
parent[x] = root;
|
|
76
|
-
x = next;
|
|
77
|
-
}
|
|
78
|
-
return root;
|
|
79
|
-
};
|
|
80
|
-
const union = (a, b) => { const ra = find(a); const rb = find(b); if (ra !== rb)
|
|
81
|
-
parent[ra] = rb; };
|
|
82
|
-
// Exact-hash buckets first (cheap, and the only signal for undecodable icons).
|
|
83
|
-
const firstByHash = new Map();
|
|
84
|
-
items.forEach((r, i) => {
|
|
85
|
-
if (!r.faviconHash)
|
|
86
|
-
return;
|
|
87
|
-
const existing = firstByHash.get(r.faviconHash);
|
|
88
|
-
if (existing !== undefined)
|
|
89
|
-
union(i, existing);
|
|
90
|
-
else
|
|
91
|
-
firstByHash.set(r.faviconHash, i);
|
|
92
|
-
});
|
|
93
|
-
// Perceptual near-matches among records with an *informative* phash -
|
|
94
|
-
// degenerate hashes (solid/monotonic icons) are skipped so they don't all
|
|
95
|
-
// cluster together; they can still group via an exact-hash match above.
|
|
96
|
-
const withPhash = [];
|
|
97
|
-
items.forEach((r, i) => {
|
|
98
|
-
if (isInformativeFaviconHash(r.faviconPHash))
|
|
99
|
-
withPhash.push({ i, phash: r.faviconPHash });
|
|
100
|
-
});
|
|
101
|
-
for (let a = 0; a < withPhash.length; a += 1) {
|
|
102
|
-
for (let b = a + 1; b < withPhash.length; b += 1) {
|
|
103
|
-
const left = withPhash[a];
|
|
104
|
-
const right = withPhash[b];
|
|
105
|
-
if (!left || !right)
|
|
106
|
-
continue;
|
|
107
|
-
const distance = hammingDistanceHex(left.phash, right.phash);
|
|
108
|
-
if (distance !== null && distance <= maxDistance)
|
|
109
|
-
union(left.i, right.i);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
const groups = new Map();
|
|
113
|
-
items.forEach((r, i) => {
|
|
114
|
-
const root = find(i);
|
|
115
|
-
if (!groups.has(root))
|
|
116
|
-
groups.set(root, []);
|
|
117
|
-
groups.get(root)?.push(r.domain);
|
|
118
|
-
});
|
|
119
|
-
return [...groups.values()].filter((domains) => domains.length >= 2);
|
|
120
|
-
}
|
|
121
|
-
export function toCsvValue(v) {
|
|
122
|
-
return serializeCsvCell(v);
|
|
123
|
-
}
|
|
124
|
-
export function rowsToCsv(rows) {
|
|
125
|
-
return serializeCsvRows(rows);
|
|
126
|
-
}
|
|
127
|
-
function splitDelimitedLine(line, delimiter = ',') {
|
|
128
|
-
const result = [];
|
|
129
|
-
let cur = '';
|
|
130
|
-
let inQuotes = false;
|
|
131
|
-
for (let i = 0; i < line.length; i += 1) {
|
|
132
|
-
const ch = line[i];
|
|
133
|
-
if (ch === '"') {
|
|
134
|
-
if (inQuotes && line[i + 1] === '"') {
|
|
135
|
-
cur += '"';
|
|
136
|
-
i += 1;
|
|
137
|
-
}
|
|
138
|
-
else {
|
|
139
|
-
inQuotes = !inQuotes;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
else if (ch === delimiter && !inQuotes) {
|
|
143
|
-
result.push(cur);
|
|
144
|
-
cur = '';
|
|
145
|
-
}
|
|
146
|
-
else {
|
|
147
|
-
cur += ch;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
result.push(cur);
|
|
151
|
-
return result.map((s) => s.trim());
|
|
152
|
-
}
|
|
153
|
-
const DOMAIN_HEADER_NAMES = ['domain', 'domain_name', 'domain name', 'hostname', 'name'];
|
|
154
|
-
export const MAX_DOMAIN_INPUT_CHARACTERS = 2 * 1024 * 1024;
|
|
155
|
-
export const MAX_DOMAIN_INPUT_BYTES = 2 * 1024 * 1024;
|
|
156
|
-
export const MAX_DOMAIN_INPUT_LINES = 10_000;
|
|
157
|
-
export const MAX_DOMAIN_INPUT_SEPARATORS = 20_000;
|
|
158
|
-
function detectDelimiter(line) {
|
|
159
|
-
const candidates = [',', ';', '\t'];
|
|
160
|
-
let best = null;
|
|
161
|
-
let bestCount = 0;
|
|
162
|
-
for (const delimiter of candidates) {
|
|
163
|
-
const count = splitDelimitedLine(line, delimiter).length - 1;
|
|
164
|
-
if (count > bestCount) {
|
|
165
|
-
best = delimiter;
|
|
166
|
-
bestCount = count;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
return best;
|
|
170
|
-
}
|
|
171
|
-
// Used only to distinguish a pasted delimiter-separated query list from a
|
|
172
|
-
// headerless multi-column CSV. Server-side classifyQuery remains the
|
|
173
|
-
// authoritative validator when the scan runs.
|
|
174
|
-
function looksLikeLookupToken(value) {
|
|
175
|
-
const token = String(value || '').trim();
|
|
176
|
-
if (!token || /\s/.test(token))
|
|
177
|
-
return false;
|
|
178
|
-
if (/^AS\d+$/i.test(token) || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(token) || token.includes(':'))
|
|
179
|
-
return true;
|
|
180
|
-
try {
|
|
181
|
-
const url = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(token) ? token : `https://${token}`);
|
|
182
|
-
return url.hostname.includes('.');
|
|
183
|
-
}
|
|
184
|
-
catch {
|
|
185
|
-
return false;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
export function parseDomainInput(text) {
|
|
189
|
-
const source = String(text || '');
|
|
190
|
-
if (source.length > MAX_DOMAIN_INPUT_CHARACTERS) {
|
|
191
|
-
return { entries: [], duplicates: 0, usedHeader: false, tooLarge: true };
|
|
192
|
-
}
|
|
193
|
-
if (new TextEncoder().encode(source).byteLength > MAX_DOMAIN_INPUT_BYTES) {
|
|
194
|
-
return { entries: [], duplicates: 0, usedHeader: false, tooLarge: true };
|
|
195
|
-
}
|
|
196
|
-
let lineCount = 1;
|
|
197
|
-
let separatorCount = 0;
|
|
198
|
-
for (let index = 0; index < source.length; index += 1) {
|
|
199
|
-
const character = source[index];
|
|
200
|
-
if (character === '\n')
|
|
201
|
-
lineCount += 1;
|
|
202
|
-
if (character === ',' || character === ';' || character === '\t')
|
|
203
|
-
separatorCount += 1;
|
|
204
|
-
if (lineCount > MAX_DOMAIN_INPUT_LINES || separatorCount > MAX_DOMAIN_INPUT_SEPARATORS) {
|
|
205
|
-
return { entries: [], duplicates: 0, usedHeader: false, tooLarge: true };
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
const normalized = source.replace(/^\uFEFF/, '');
|
|
209
|
-
const lines = normalized.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
210
|
-
if (lines.length === 0)
|
|
211
|
-
return { entries: [], duplicates: 0, usedHeader: false, tooLarge: false };
|
|
212
|
-
const firstLine = lines[0] ?? '';
|
|
213
|
-
const delimiter = detectDelimiter(firstLine);
|
|
214
|
-
const firstCells = delimiter ? splitDelimitedLine(firstLine, delimiter) : [firstLine];
|
|
215
|
-
const header = firstCells.map((cell) => cell.toLowerCase());
|
|
216
|
-
const domainColIdx = header.findIndex((cell) => DOMAIN_HEADER_NAMES.includes(cell));
|
|
217
|
-
const usedHeader = domainColIdx !== -1;
|
|
218
|
-
let candidates;
|
|
219
|
-
if (usedHeader) {
|
|
220
|
-
candidates = lines.slice(1).map((line) => {
|
|
221
|
-
const rowDelimiter = delimiter || detectDelimiter(line);
|
|
222
|
-
const cells = rowDelimiter ? splitDelimitedLine(line, rowDelimiter) : [line];
|
|
223
|
-
return cells[domainColIdx] || '';
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
else {
|
|
227
|
-
const rows = lines.map((line) => {
|
|
228
|
-
const rowDelimiter = detectDelimiter(line);
|
|
229
|
-
return rowDelimiter ? splitDelimitedLine(line, rowDelimiter) : [line];
|
|
230
|
-
});
|
|
231
|
-
const flattened = rows.flat().map((cell) => cell.trim()).filter(Boolean);
|
|
232
|
-
// If every cell looks like a query, this is a pasted comma/semicolon list
|
|
233
|
-
// (possibly wrapped over several lines). Otherwise retain column zero so
|
|
234
|
-
// a headerless "domain,notes" CSV does not scan its notes as domains.
|
|
235
|
-
candidates = lines.length === 1 || (flattened.length > 0 && flattened.every(looksLikeLookupToken))
|
|
236
|
-
? flattened
|
|
237
|
-
: rows.map((cells) => cells[0] || '');
|
|
238
|
-
}
|
|
239
|
-
const entries = [];
|
|
240
|
-
const seen = new Set();
|
|
241
|
-
let duplicates = 0;
|
|
242
|
-
for (const candidate of candidates) {
|
|
243
|
-
const value = String(candidate || '').trim();
|
|
244
|
-
if (!value)
|
|
245
|
-
continue;
|
|
246
|
-
const key = value.toLowerCase();
|
|
247
|
-
if (seen.has(key)) {
|
|
248
|
-
duplicates += 1;
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
seen.add(key);
|
|
252
|
-
entries.push(value);
|
|
253
|
-
}
|
|
254
|
-
return { entries, duplicates, usedHeader, tooLarge: false };
|
|
255
|
-
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
export function boundedVisualizationText(value, maxLength) {
|
|
2
|
-
return String(value ?? '')
|
|
3
|
-
.replace(/[\u0000-\u001f\u007f]/gu, ' ')
|
|
4
|
-
.replace(/\s+/gu, ' ')
|
|
5
|
-
.trim()
|
|
6
|
-
.slice(0, maxLength);
|
|
7
|
-
}
|
|
8
|
-
export function boundedVisualizationId(value) {
|
|
9
|
-
return boundedVisualizationText(value, 64)
|
|
10
|
-
.toLowerCase()
|
|
11
|
-
.replace(/[^a-z0-9_-]+/gu, '-')
|
|
12
|
-
.replace(/^-+|-+$/gu, '');
|
|
13
|
-
}
|
|
14
|
-
export function boundedVisualizationNumber(value, minimum, maximum, fallback = 0) {
|
|
15
|
-
const number = Number(value);
|
|
16
|
-
return Number.isFinite(number)
|
|
17
|
-
? Math.max(minimum, Math.min(maximum, number))
|
|
18
|
-
: fallback;
|
|
19
|
-
}
|
|
20
|
-
export function validVisualizationDate(value) {
|
|
21
|
-
const text = boundedVisualizationText(value, 64);
|
|
22
|
-
const milliseconds = Date.parse(text);
|
|
23
|
-
return Number.isFinite(milliseconds)
|
|
24
|
-
? { text: new Date(milliseconds).toISOString(), milliseconds }
|
|
25
|
-
: null;
|
|
26
|
-
}
|
|
@@ -1,352 +0,0 @@
|
|
|
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
|
-
}
|