@andespindola/brainlink 1.0.4 → 1.0.6
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 +17 -9
- package/dist/application/add-note.js +2 -2
- package/dist/application/build-context.js +16 -10
- package/dist/application/canonical-context-links.js +44 -5
- package/dist/application/check-package-update.js +105 -0
- package/dist/application/frontend/client/chunk-fetch.js +236 -0
- package/dist/application/frontend/client/controls.js +178 -0
- package/dist/application/frontend/client/elements.js +122 -0
- package/dist/application/frontend/client/input.js +202 -0
- package/dist/application/frontend/client/node-details.js +191 -0
- package/dist/application/frontend/client/rendering.js +296 -0
- package/dist/application/frontend/client/scope-theme.js +114 -0
- package/dist/application/frontend/client/spatial.js +98 -0
- package/dist/application/frontend/client/storage.js +215 -0
- package/dist/application/frontend/client/upload.js +90 -0
- package/dist/application/frontend/client/worker-bootstrap.js +147 -0
- package/dist/application/frontend/client-js.js +24 -1837
- package/dist/application/frontend/client-render-worker-js.js +1 -1
- package/dist/application/index-vault-phases.js +189 -0
- package/dist/application/index-vault.js +44 -165
- package/dist/application/server/routes.js +12 -9
- package/dist/cli/commands/write/dedupe-commands.js +59 -0
- package/dist/cli/commands/write/index-commands.js +205 -0
- package/dist/cli/commands/write/link-commands.js +68 -0
- package/dist/cli/commands/write/note-commands.js +146 -0
- package/dist/cli/commands/write/server-commands.js +553 -0
- package/dist/cli/commands/write/shared.js +35 -0
- package/dist/cli/commands/write/vault-lifecycle-commands.js +270 -0
- package/dist/cli/commands/write-commands.js +12 -1303
- package/dist/cli/main.js +39 -3
- package/dist/domain/context.js +39 -3
- package/dist/domain/embeddings.js +31 -5
- package/dist/domain/graph-contexts.js +62 -57
- package/dist/domain/graph-layout/cauliflower-layout.js +116 -0
- package/dist/domain/graph-layout/collisions.js +100 -0
- package/dist/domain/graph-layout/hierarchy.js +135 -0
- package/dist/domain/graph-layout/metrics.js +111 -0
- package/dist/domain/graph-layout/segments.js +76 -0
- package/dist/domain/graph-layout/star-layout.js +110 -0
- package/dist/domain/graph-layout.js +4 -625
- package/dist/infrastructure/config.js +10 -4
- package/dist/infrastructure/file-index.js +13 -4
- package/dist/infrastructure/semantic-prefilter.js +24 -0
- package/dist/mcp/server.js +7 -0
- package/dist/mcp/tool-guard.js +29 -0
- package/dist/mcp/tools/maintenance-tools.js +409 -0
- package/dist/mcp/tools/read-tools.js +504 -0
- package/dist/mcp/tools/shared.js +216 -0
- package/dist/mcp/tools/write-tools.js +247 -0
- package/dist/mcp/tools.js +3 -1357
- package/docs/AGENT_USAGE.md +4 -4
- package/docs/QUICKSTART.md +5 -1
- package/package.json +2 -2
package/dist/cli/main.js
CHANGED
|
@@ -3,17 +3,52 @@ import { Command } from 'commander';
|
|
|
3
3
|
import { readFileSync } from 'node:fs';
|
|
4
4
|
import { basename, dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { checkPackageUpdate } from '../application/check-package-update.js';
|
|
7
|
+
import { loadBrainlinkConfig } from '../infrastructure/config.js';
|
|
6
8
|
import { registerAgentCommands } from './commands/agent-commands.js';
|
|
7
9
|
import { registerConfigCommands } from './commands/config-commands.js';
|
|
8
10
|
import { registerPracticalCommands } from './commands/practical-commands.js';
|
|
9
11
|
import { registerReadCommands } from './commands/read-commands.js';
|
|
10
12
|
import { registerVaultCommands } from './commands/vault-commands.js';
|
|
11
13
|
import { registerWriteCommands } from './commands/write-commands.js';
|
|
12
|
-
const
|
|
14
|
+
const readPackageMetadata = () => {
|
|
13
15
|
const packagePath = join(dirname(fileURLToPath(import.meta.url)), '../../package.json');
|
|
14
16
|
const metadata = JSON.parse(readFileSync(packagePath, 'utf8'));
|
|
15
|
-
return
|
|
17
|
+
return {
|
|
18
|
+
name: metadata.name ?? 'brainlink',
|
|
19
|
+
version: metadata.version ?? '0.0.0'
|
|
20
|
+
};
|
|
16
21
|
};
|
|
22
|
+
const shouldSkipUpdateCheck = () => process.env.BRAINLINK_NO_UPDATE_CHECK === '1' ||
|
|
23
|
+
process.env.BRAINLINK_NO_UPDATE_CHECK === 'true' ||
|
|
24
|
+
process.env.CI === 'true' ||
|
|
25
|
+
process.env.VITEST === 'true' ||
|
|
26
|
+
process.env.NODE_ENV === 'test' ||
|
|
27
|
+
process.argv.includes('--version') ||
|
|
28
|
+
process.argv.includes('-V') ||
|
|
29
|
+
process.argv.includes('--help') ||
|
|
30
|
+
process.argv.includes('-h');
|
|
31
|
+
const maybePrintUpdateNotice = async (metadata) => {
|
|
32
|
+
if (shouldSkipUpdateCheck()) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const config = await loadBrainlinkConfig();
|
|
37
|
+
const status = await checkPackageUpdate({
|
|
38
|
+
packageName: metadata.name,
|
|
39
|
+
currentVersion: metadata.version,
|
|
40
|
+
enabled: config.autoUpdateCheck,
|
|
41
|
+
intervalMs: config.updateCheckIntervalMs
|
|
42
|
+
});
|
|
43
|
+
if (!status.skipped && status.updateAvailable && status.latestVersion) {
|
|
44
|
+
console.error(`Brainlink ${status.latestVersion} is available. Current version: ${status.currentVersion}. Update with: ${status.installCommand}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Update checks must never block normal CLI execution.
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const packageMetadata = readPackageMetadata();
|
|
17
52
|
const program = new Command();
|
|
18
53
|
const cliName = basename(process.argv[1] ?? 'brainlink');
|
|
19
54
|
const displayName = cliName === 'blink' ? 'blink' : 'brainlink';
|
|
@@ -22,13 +57,14 @@ program
|
|
|
22
57
|
.name(displayName)
|
|
23
58
|
.alias(aliasName)
|
|
24
59
|
.description('Local-first knowledge memory for agents')
|
|
25
|
-
.version(
|
|
60
|
+
.version(packageMetadata.version);
|
|
26
61
|
registerWriteCommands(program);
|
|
27
62
|
registerReadCommands(program);
|
|
28
63
|
registerPracticalCommands(program);
|
|
29
64
|
registerConfigCommands(program);
|
|
30
65
|
registerVaultCommands(program);
|
|
31
66
|
registerAgentCommands(program);
|
|
67
|
+
await maybePrintUpdateNotice(packageMetadata);
|
|
32
68
|
program.parseAsync().catch((error) => {
|
|
33
69
|
const message = error instanceof Error ? error.message : String(error);
|
|
34
70
|
console.error(message);
|
package/dist/domain/context.js
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { middleOutIndices } from './middle-out.js';
|
|
2
2
|
const maxSectionsPerDocument = 3;
|
|
3
|
+
const charsPerToken = 4;
|
|
4
|
+
const sectionSeparatorTokens = 1;
|
|
5
|
+
const headerReserveTokens = 8;
|
|
6
|
+
const estimateTokens = (text) => Math.ceil(text.length / charsPerToken);
|
|
7
|
+
const truncateToTokens = (text, maxTokens) => {
|
|
8
|
+
const maxChars = Math.max(0, maxTokens) * charsPerToken;
|
|
9
|
+
return text.length <= maxChars ? text : text.slice(0, maxChars);
|
|
10
|
+
};
|
|
11
|
+
// Mirror the per-section framing that formatContextPackage renders (heading,
|
|
12
|
+
// Source/Tags/Score/Mode lines and the trailing separator) so the budget
|
|
13
|
+
// reflects the package the agent actually receives, not just chunk content.
|
|
14
|
+
const estimateSectionTokens = (result) => {
|
|
15
|
+
const tagsLine = result.tags.length > 0 ? `Tags: ${result.tags.map((tag) => `#${tag}`).join(' ')}\n` : '';
|
|
16
|
+
const framing = `## . ${result.title}\nSource: ${result.path}\n${tagsLine}Score: 0.000\nMode: ${result.searchMode}\n\n`;
|
|
17
|
+
return estimateTokens(result.content) + estimateTokens(framing) + sectionSeparatorTokens;
|
|
18
|
+
};
|
|
3
19
|
const byScore = (left, right) => right.score - left.score || left.title.localeCompare(right.title);
|
|
4
20
|
const byOrdinal = (left, right) => (left.chunkOrdinal ?? Number.MAX_SAFE_INTEGER) - (right.chunkOrdinal ?? Number.MAX_SAFE_INTEGER);
|
|
5
21
|
const middleOutDocumentResults = (results) => {
|
|
@@ -38,7 +54,7 @@ export const selectContextSections = (results, maxTokens) => {
|
|
|
38
54
|
if (seenChunks.has(result.chunkId)) {
|
|
39
55
|
continue;
|
|
40
56
|
}
|
|
41
|
-
const tokenCost =
|
|
57
|
+
const tokenCost = estimateSectionTokens(result);
|
|
42
58
|
if (usedTokens + tokenCost > maxTokens) {
|
|
43
59
|
break;
|
|
44
60
|
}
|
|
@@ -62,11 +78,31 @@ export const selectContextSections = (results, maxTokens) => {
|
|
|
62
78
|
seenChunks
|
|
63
79
|
};
|
|
64
80
|
}, {
|
|
65
|
-
usedTokens:
|
|
81
|
+
usedTokens: headerReserveTokens,
|
|
66
82
|
sections: [],
|
|
67
83
|
seenChunks: new Set()
|
|
68
84
|
});
|
|
69
|
-
|
|
85
|
+
if (selected.sections.length > 0 || results.length === 0) {
|
|
86
|
+
return selected.sections;
|
|
87
|
+
}
|
|
88
|
+
// Retrieval found matches but every chunk overflowed the token budget. Never
|
|
89
|
+
// surface an empty context in that case: include the highest-scored chunk,
|
|
90
|
+
// truncated so the rendered package still respects the budget. This keeps
|
|
91
|
+
// brainlink_context consistent with brainlink_search instead of reporting
|
|
92
|
+
// "No relevant context found." whenever the strongest chunk is large.
|
|
93
|
+
const topResult = [...results].sort(byScore)[0];
|
|
94
|
+
const framingTokens = estimateSectionTokens({ ...topResult, content: '' });
|
|
95
|
+
const contentTokenBudget = maxTokens - headerReserveTokens - framingTokens;
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
title: topResult.title,
|
|
99
|
+
path: topResult.path,
|
|
100
|
+
content: truncateToTokens(topResult.content, contentTokenBudget),
|
|
101
|
+
score: topResult.score,
|
|
102
|
+
searchMode: topResult.searchMode,
|
|
103
|
+
tags: topResult.tags
|
|
104
|
+
}
|
|
105
|
+
];
|
|
70
106
|
};
|
|
71
107
|
export const formatContextPackage = (query, sections) => {
|
|
72
108
|
const body = sections
|
|
@@ -67,8 +67,16 @@ const featureHash = (feature) => {
|
|
|
67
67
|
const sign = value & 1 ? 1 : -1;
|
|
68
68
|
return [index, sign];
|
|
69
69
|
};
|
|
70
|
+
const vectorMagnitude = (vector) => {
|
|
71
|
+
let sumSquares = 0;
|
|
72
|
+
for (let index = 0; index < vector.length; index += 1) {
|
|
73
|
+
const value = vector[index];
|
|
74
|
+
sumSquares += value * value;
|
|
75
|
+
}
|
|
76
|
+
return Math.sqrt(sumSquares);
|
|
77
|
+
};
|
|
70
78
|
const normalizeVector = (vector) => {
|
|
71
|
-
const magnitude =
|
|
79
|
+
const magnitude = vectorMagnitude(vector);
|
|
72
80
|
return magnitude === 0 ? vector : vector.map((value) => value / magnitude);
|
|
73
81
|
};
|
|
74
82
|
const applyFeature = (vector, feature, weight) => {
|
|
@@ -86,15 +94,33 @@ export const createLocalEmbedding = (input) => {
|
|
|
86
94
|
const weighted = tokenFeatures(tokens).reduce((vector, feature) => applyFeature(vector, feature, feature.startsWith('b:') ? 0.65 : 1), initial);
|
|
87
95
|
return normalizeVector(weighted);
|
|
88
96
|
};
|
|
97
|
+
// Dot product over the shared prefix. For L2-normalized vectors (every vector
|
|
98
|
+
// from createLocalEmbedding is normalized) this equals cosine similarity, so
|
|
99
|
+
// the hot retrieval loop can skip the per-comparison magnitude recompute.
|
|
100
|
+
export const dotProduct = (left, right) => {
|
|
101
|
+
const length = Math.min(left.length, right.length);
|
|
102
|
+
let total = 0;
|
|
103
|
+
for (let index = 0; index < length; index += 1) {
|
|
104
|
+
total += left[index] * right[index];
|
|
105
|
+
}
|
|
106
|
+
return total;
|
|
107
|
+
};
|
|
89
108
|
export const cosineSimilarity = (left, right) => {
|
|
90
109
|
const length = Math.min(left.length, right.length);
|
|
91
110
|
if (length === 0) {
|
|
92
111
|
return 0;
|
|
93
112
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
113
|
+
let dot = 0;
|
|
114
|
+
let leftSumSquares = 0;
|
|
115
|
+
let rightSumSquares = 0;
|
|
116
|
+
for (let index = 0; index < length; index += 1) {
|
|
117
|
+
const leftValue = left[index];
|
|
118
|
+
const rightValue = right[index];
|
|
119
|
+
dot += leftValue * rightValue;
|
|
120
|
+
leftSumSquares += leftValue * leftValue;
|
|
121
|
+
rightSumSquares += rightValue * rightValue;
|
|
122
|
+
}
|
|
123
|
+
return leftSumSquares === 0 || rightSumSquares === 0 ? 0 : dot / Math.sqrt(leftSumSquares * rightSumSquares);
|
|
98
124
|
};
|
|
99
125
|
const bucketKey = (index, value) => `${value >= 0 ? 'p' : 'n'}:${index}`;
|
|
100
126
|
export const createEmbeddingBuckets = (vector, bucketCount = defaultEmbeddingBucketCount) => vector
|
|
@@ -8,7 +8,59 @@ const context = (title) => ({
|
|
|
8
8
|
const byTitle = (left, right) => left.title.localeCompare(right.title);
|
|
9
9
|
const edgeKey = (source, target) => source < target ? `${source}|${target}` : `${target}|${source}`;
|
|
10
10
|
const nodeSearchText = (node) => normalize([node.title, node.path, ...node.tags].join(' '));
|
|
11
|
-
|
|
11
|
+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
12
|
+
// Hub notes name their context as "<Context> Hub". These reserved hubs are
|
|
13
|
+
// structural anchors of the graph, not visual contexts, so they never become
|
|
14
|
+
// keyword rules.
|
|
15
|
+
const reservedHubTitles = new Set(['memory hub', 'knowledge root']);
|
|
16
|
+
const hubContextPattern = /^(.+?)\s+hub$/i;
|
|
17
|
+
// Extra keywords can be declared in a hub note body via a "Keywords: a, b, c"
|
|
18
|
+
// line so a context can match terms beyond its own name.
|
|
19
|
+
const parseHubKeywords = (content) => {
|
|
20
|
+
const match = content.match(/^\s*keywords?\s*:\s*(.+)$/im);
|
|
21
|
+
if (!match) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
return match[1]
|
|
25
|
+
.split(',')
|
|
26
|
+
.map((keyword) => normalize(keyword))
|
|
27
|
+
.filter((keyword) => keyword.length > 0);
|
|
28
|
+
};
|
|
29
|
+
// Derive content-classification rules from the hub notes present in the graph.
|
|
30
|
+
// A "<Context> Hub" note contributes the context, keyed by its normalized name
|
|
31
|
+
// plus any declared extra keywords. This keeps vault-specific taxonomy in the
|
|
32
|
+
// vault instead of hardcoding it in the engine.
|
|
33
|
+
export const deriveVisualContextRules = (nodes) => {
|
|
34
|
+
const rules = new Map();
|
|
35
|
+
nodes.forEach((node) => {
|
|
36
|
+
const match = node.title.trim().match(hubContextPattern);
|
|
37
|
+
if (!match) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (reservedHubTitles.has(normalize(node.title))) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const contextTitle = match[1].trim();
|
|
44
|
+
if (contextTitle.length === 0) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const keywords = rules.get(contextTitle) ?? new Set();
|
|
48
|
+
keywords.add(normalize(contextTitle));
|
|
49
|
+
parseHubKeywords(node.content).forEach((keyword) => keywords.add(keyword));
|
|
50
|
+
rules.set(contextTitle, keywords);
|
|
51
|
+
});
|
|
52
|
+
return Array.from(rules.entries(), ([contextTitle, keywords]) => ({
|
|
53
|
+
context: contextTitle,
|
|
54
|
+
keywords: Array.from(keywords)
|
|
55
|
+
}));
|
|
56
|
+
};
|
|
57
|
+
const matchRule = (text, rules) => {
|
|
58
|
+
const matched = rules.find((rule) => rule.keywords.some((keyword) => new RegExp(`\\b${escapeRegExp(keyword)}\\b`).test(text)));
|
|
59
|
+
return matched ? context(matched.context) : null;
|
|
60
|
+
};
|
|
61
|
+
// Structural, vault-agnostic classification. Named-hub phrases and path layout
|
|
62
|
+
// are generic conventions; vault-specific contexts come from `rules`.
|
|
63
|
+
export const inferExplicitVisualGraphContext = (node, rules = []) => {
|
|
12
64
|
const text = nodeSearchText(node);
|
|
13
65
|
const path = normalize(node.path);
|
|
14
66
|
if (includesAny(text, [/\bgithub repositories hub\b/]))
|
|
@@ -25,80 +77,32 @@ export const inferExplicitVisualGraphContext = (node) => {
|
|
|
25
77
|
return context('Git Workflow');
|
|
26
78
|
if (includesAny(text, [/\bagent memory hub\b/]))
|
|
27
79
|
return context('Agent Memory');
|
|
28
|
-
if (includesAny(text, [/pingu_ai_codding_pair_programming/, /\bpingu\b/]))
|
|
29
|
-
return context('Pingu');
|
|
30
80
|
if (path.startsWith('github-repos/'))
|
|
31
81
|
return context('GitHub Repositories');
|
|
32
82
|
if (path.startsWith('github-org-repos/'))
|
|
33
83
|
return context('GitHub Organizations');
|
|
34
84
|
if (path.startsWith('machine-config/'))
|
|
35
85
|
return context('Machine Configuration');
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return context('AnonSpace');
|
|
40
|
-
if (includesAny(text, [/\bsubstructa\b/]))
|
|
41
|
-
return context('Substructa');
|
|
42
|
-
if (includesAny(text, [/\bnebula\b/]))
|
|
43
|
-
return context('Nebula');
|
|
44
|
-
if (includesAny(text, [/\bsnippets?\b/, /\bupgrader\b/, /\bversion-map\b/]))
|
|
45
|
-
return context('Snippets');
|
|
46
|
-
if (includesAny(text, [
|
|
47
|
-
/\bpreference\b/,
|
|
48
|
-
/\bpreferences\b/,
|
|
49
|
-
/\bpreferencia\b/,
|
|
50
|
-
/\bpreferencias\b/,
|
|
51
|
-
/\bpreferência\b/,
|
|
52
|
-
/\bpreferências\b/,
|
|
53
|
-
/\bplaybook\b/,
|
|
54
|
-
/\bdirective\b/,
|
|
55
|
-
/\bdirectives\b/,
|
|
56
|
-
/\bdiretiva\b/,
|
|
57
|
-
/\bdiretivas\b/,
|
|
58
|
-
/\bengineering-style\b/,
|
|
59
|
-
/\bglobal-engineering\b/,
|
|
60
|
-
/\bcoding-identity\b/,
|
|
61
|
-
/\bagents\.md\b/,
|
|
62
|
-
/\bagents-md\b/,
|
|
63
|
-
/\bordem direta\b/,
|
|
64
|
-
/\bordem-direta\b/,
|
|
65
|
-
/\bman-in-the-loop\b/,
|
|
66
|
-
/\bconfig geral\b/,
|
|
67
|
-
/\bconfig-geral\b/,
|
|
68
|
-
/\bsync config_files\b/,
|
|
69
|
-
/\bsync-config-files\b/,
|
|
70
|
-
/\bregra operacional\b/,
|
|
71
|
-
/\bregras operacionais\b/,
|
|
72
|
-
/\boperational rule\b/,
|
|
73
|
-
/\boperational rules\b/,
|
|
74
|
-
/\boperational policy\b/
|
|
75
|
-
])) {
|
|
76
|
-
return context('User Preferences');
|
|
86
|
+
const ruleMatch = matchRule(text, rules);
|
|
87
|
+
if (ruleMatch) {
|
|
88
|
+
return ruleMatch;
|
|
77
89
|
}
|
|
78
|
-
if (includesAny(text, [/\binkdrop\b/]))
|
|
79
|
-
return context('Inkdrop');
|
|
80
|
-
if (includesAny(text, [/\blazyvim\b/, /\bneovim\b/, /\bnvim\b/, /\bmason\b/, /\bwrapper\b/]))
|
|
81
|
-
return context('Neovim LazyVim');
|
|
82
|
-
if (includesAny(text, [/\bgit-flow\b/, /\borigin-sync\b/, /\bgit-identidade\b/, /\bcommit\b/, /\bpush\b/]))
|
|
83
|
-
return context('Git Workflow');
|
|
84
|
-
if (includesAny(text, [/\bdocker\b/, /\bkubernetes\b/, /\bdeploy\b/, /\bredeploy\b/]))
|
|
85
|
-
return context('Operations');
|
|
86
90
|
if (path.startsWith('agents/'))
|
|
87
91
|
return context('Agent Memory');
|
|
88
92
|
return null;
|
|
89
93
|
};
|
|
90
|
-
export const inferVisualGraphContext = (node) => {
|
|
91
|
-
const explicit = inferExplicitVisualGraphContext(node);
|
|
94
|
+
export const inferVisualGraphContext = (node, rules = []) => {
|
|
95
|
+
const explicit = inferExplicitVisualGraphContext(node, rules);
|
|
92
96
|
if (explicit) {
|
|
93
97
|
return explicit;
|
|
94
98
|
}
|
|
95
99
|
const [root] = node.path.split('/').filter(Boolean);
|
|
96
100
|
return context(root ? root.replace(/[-_]+/g, ' ') : 'Root');
|
|
97
101
|
};
|
|
98
|
-
export const groupNodesByVisualContext = (nodes) => {
|
|
102
|
+
export const groupNodesByVisualContext = (nodes, rules = deriveVisualContextRules(nodes)) => {
|
|
99
103
|
const groups = new Map();
|
|
100
104
|
nodes.forEach((node) => {
|
|
101
|
-
const visualContext = inferVisualGraphContext(node);
|
|
105
|
+
const visualContext = inferVisualGraphContext(node, rules);
|
|
102
106
|
const bucket = groups.get(visualContext.title);
|
|
103
107
|
if (bucket) {
|
|
104
108
|
bucket.push(node);
|
|
@@ -147,8 +151,9 @@ export const addVisualContextEdges = (graph) => {
|
|
|
147
151
|
.filter((edge) => Boolean(edge.target))
|
|
148
152
|
.map((edge) => edgeKey(edge.source, edge.target)));
|
|
149
153
|
const degrees = countDegrees(graph.edges);
|
|
154
|
+
const rules = deriveVisualContextRules(graph.nodes);
|
|
150
155
|
const derivedEdges = [];
|
|
151
|
-
for (const [contextTitle, nodes] of groupNodesByVisualContext(graph.nodes).entries()) {
|
|
156
|
+
for (const [contextTitle, nodes] of groupNodesByVisualContext(graph.nodes, rules).entries()) {
|
|
152
157
|
if (nodes.length <= 1) {
|
|
153
158
|
continue;
|
|
154
159
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { byDegreeThenTitle, centerLayoutByNode, countDegrees, groupKey, groupLabel, hubScore, jitter, segmentAngle, selectHighestDegreeNodeId, selectPrimaryHubId } from './metrics.js';
|
|
2
|
+
import { assignSegments, groupNodesBySegment } from './segments.js';
|
|
3
|
+
import { relaxCollisions } from './collisions.js';
|
|
4
|
+
import { createGraphLayoutHierarchy } from './hierarchy.js';
|
|
5
|
+
const petalRadiusForSegmentSize = (size) => {
|
|
6
|
+
const safeSize = Math.max(size, 1);
|
|
7
|
+
return Math.max(260, Math.sqrt(safeSize) * 96);
|
|
8
|
+
};
|
|
9
|
+
const selectSegmentHub = (nodes, degrees, primaryHubId) => {
|
|
10
|
+
const primary = nodes.find((node) => node.id === primaryHubId);
|
|
11
|
+
if (primary) {
|
|
12
|
+
return primary;
|
|
13
|
+
}
|
|
14
|
+
return [...nodes].sort((left, right) => {
|
|
15
|
+
const hubDelta = hubScore(right) - hubScore(left);
|
|
16
|
+
if (hubDelta !== 0)
|
|
17
|
+
return hubDelta;
|
|
18
|
+
const degreeDelta = (degrees.get(right.id) ?? 0) - (degrees.get(left.id) ?? 0);
|
|
19
|
+
if (degreeDelta !== 0)
|
|
20
|
+
return degreeDelta;
|
|
21
|
+
return left.title.localeCompare(right.title);
|
|
22
|
+
})[0] ?? null;
|
|
23
|
+
};
|
|
24
|
+
const segmentCenterRadius = (segments) => {
|
|
25
|
+
if (segments.length <= 1) {
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
const circumference = segments.reduce((total, [, nodes]) => total + petalRadiusForSegmentSize(nodes.length) * 2 + 180, 0);
|
|
29
|
+
return Math.max(520, circumference / (Math.PI * 2));
|
|
30
|
+
};
|
|
31
|
+
const createCauliflowerSegmentNodes = (segments, degrees, rootHubId, segmentGroups) => ([segment, nodes], segmentIndex) => {
|
|
32
|
+
const sortedNodes = [...nodes].sort(byDegreeThenTitle(degrees));
|
|
33
|
+
const segmentHub = selectSegmentHub(sortedNodes, degrees, rootHubId);
|
|
34
|
+
const angle = segmentAngle(segment, segmentIndex, segmentGroups.length);
|
|
35
|
+
const globalRadius = segmentCenterRadius(segmentGroups);
|
|
36
|
+
const petalRadius = petalRadiusForSegmentSize(sortedNodes.length);
|
|
37
|
+
const isPrimarySegment = Boolean(segmentHub && segmentHub.id === rootHubId);
|
|
38
|
+
const centerX = isPrimarySegment || globalRadius === 0 ? 0 : Math.cos(angle) * globalRadius;
|
|
39
|
+
const centerY = isPrimarySegment || globalRadius === 0 ? 0 : Math.sin(angle) * (globalRadius * 0.86);
|
|
40
|
+
const nonHubNodes = sortedNodes.filter((node) => node.id !== segmentHub?.id);
|
|
41
|
+
const hubNode = segmentHub
|
|
42
|
+
? [{
|
|
43
|
+
...segmentHub,
|
|
44
|
+
group: groupLabel(groupKey(segmentHub)),
|
|
45
|
+
segment: segments.get(segmentHub.id) ?? segment,
|
|
46
|
+
x: centerX,
|
|
47
|
+
y: centerY
|
|
48
|
+
}]
|
|
49
|
+
: [];
|
|
50
|
+
const petalNodes = nonHubNodes.map((node, index) => {
|
|
51
|
+
const localAngle = index * 2.399963 + jitter(node.title, 0.5);
|
|
52
|
+
const radialLayer = Math.sqrt(index + 1) / Math.sqrt(Math.max(nonHubNodes.length, 1));
|
|
53
|
+
const localRadius = 150 + radialLayer * petalRadius + jitter(node.id, 34);
|
|
54
|
+
const degreePull = Math.min(degrees.get(node.id) ?? 0, 16) * 8;
|
|
55
|
+
const radius = Math.max(126, localRadius - degreePull);
|
|
56
|
+
return {
|
|
57
|
+
...node,
|
|
58
|
+
group: groupLabel(groupKey(node)),
|
|
59
|
+
segment: segments.get(node.id) ?? segment,
|
|
60
|
+
x: centerX + Math.cos(localAngle) * radius + jitter(node.title, 20),
|
|
61
|
+
y: centerY + Math.sin(localAngle) * radius * 0.84 + jitter(node.path, 20)
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
return [...hubNode, ...petalNodes];
|
|
65
|
+
};
|
|
66
|
+
const createVisualEdge = (source, target, weight, priority) => ({
|
|
67
|
+
source: source.id,
|
|
68
|
+
target: target.id,
|
|
69
|
+
targetTitle: target.title,
|
|
70
|
+
weight,
|
|
71
|
+
priority
|
|
72
|
+
});
|
|
73
|
+
const createCauliflowerVisualEdges = (segmentGroups, degrees, rootHubId) => {
|
|
74
|
+
const nodeById = new Map(segmentGroups.flatMap(([, nodes]) => nodes.map((node) => [node.id, node])));
|
|
75
|
+
const rootHub = rootHubId ? nodeById.get(rootHubId) ?? null : null;
|
|
76
|
+
const edges = new Map();
|
|
77
|
+
const addEdge = (edge) => {
|
|
78
|
+
if (!edge.target || edge.source === edge.target) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
edges.set(`${edge.source}|${edge.target}`, edge);
|
|
82
|
+
};
|
|
83
|
+
segmentGroups.forEach(([, nodes]) => {
|
|
84
|
+
const segmentHub = selectSegmentHub(nodes, degrees, rootHubId);
|
|
85
|
+
const parent = segmentHub ?? rootHub;
|
|
86
|
+
if (!parent) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (rootHub && parent.id !== rootHub.id) {
|
|
90
|
+
addEdge(createVisualEdge(rootHub, parent, 6, 'high'));
|
|
91
|
+
}
|
|
92
|
+
nodes.forEach((node) => {
|
|
93
|
+
if (node.id === parent.id || node.id === rootHub?.id) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
addEdge(createVisualEdge(parent, node, 1, 'low'));
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
return Array.from(edges.values());
|
|
100
|
+
};
|
|
101
|
+
export const createCauliflowerGraphLayout = (graph) => {
|
|
102
|
+
const degrees = countDegrees(graph.edges);
|
|
103
|
+
const segments = assignSegments(graph.nodes, graph.edges, degrees);
|
|
104
|
+
const segmentGroups = Array.from(groupNodesBySegment(graph.nodes, segments).entries())
|
|
105
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
106
|
+
const rootHubId = selectPrimaryHubId(graph.nodes, degrees) ?? selectHighestDegreeNodeId(graph.nodes, degrees);
|
|
107
|
+
const nodes = relaxCollisions(segmentGroups.flatMap(createCauliflowerSegmentNodes(segments, degrees, rootHubId, segmentGroups)), 156, 28);
|
|
108
|
+
const centeredNodes = centerLayoutByNode(nodes, rootHubId);
|
|
109
|
+
const visualEdges = createCauliflowerVisualEdges(segmentGroups, degrees, rootHubId);
|
|
110
|
+
const groups = createGraphLayoutHierarchy(centeredNodes, visualEdges, degrees);
|
|
111
|
+
return {
|
|
112
|
+
nodes: centeredNodes,
|
|
113
|
+
edges: visualEdges,
|
|
114
|
+
...(groups.length > 0 ? { groups } : {})
|
|
115
|
+
};
|
|
116
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { hashText } from './metrics.js';
|
|
2
|
+
const resolveCollisionPair = (left, right, minDistance) => {
|
|
3
|
+
const dx = right.x - left.x;
|
|
4
|
+
const dy = right.y - left.y;
|
|
5
|
+
const distance = Math.max(Math.hypot(dx, dy), 0.001);
|
|
6
|
+
if (distance >= minDistance) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
const push = (minDistance - distance) / 2;
|
|
10
|
+
const fallbackAngle = Math.PI * 2 * (Math.abs(hashText(`${left.id}:${right.id}`) % 1000) / 1000);
|
|
11
|
+
const ux = Math.abs(dx) + Math.abs(dy) < 0.001 ? Math.cos(fallbackAngle) : dx / distance;
|
|
12
|
+
const uy = Math.abs(dx) + Math.abs(dy) < 0.001 ? Math.sin(fallbackAngle) : dy / distance;
|
|
13
|
+
left.x -= ux * push;
|
|
14
|
+
left.y -= uy * push;
|
|
15
|
+
right.x += ux * push;
|
|
16
|
+
right.y += uy * push;
|
|
17
|
+
return true;
|
|
18
|
+
};
|
|
19
|
+
const buildCollisionGrid = (nodes, cellSize) => {
|
|
20
|
+
const grid = new Map();
|
|
21
|
+
nodes.forEach((node, index) => {
|
|
22
|
+
const x = Math.floor(node.x / cellSize);
|
|
23
|
+
const y = Math.floor(node.y / cellSize);
|
|
24
|
+
const key = `${x},${y}`;
|
|
25
|
+
const bucket = grid.get(key);
|
|
26
|
+
if (bucket) {
|
|
27
|
+
bucket.push(index);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
grid.set(key, [index]);
|
|
31
|
+
});
|
|
32
|
+
return grid;
|
|
33
|
+
};
|
|
34
|
+
const neighborCellKeys = (x, y) => [
|
|
35
|
+
`${x - 1},${y - 1}`,
|
|
36
|
+
`${x},${y - 1}`,
|
|
37
|
+
`${x + 1},${y - 1}`,
|
|
38
|
+
`${x - 1},${y}`,
|
|
39
|
+
`${x},${y}`,
|
|
40
|
+
`${x + 1},${y}`,
|
|
41
|
+
`${x - 1},${y + 1}`,
|
|
42
|
+
`${x},${y + 1}`,
|
|
43
|
+
`${x + 1},${y + 1}`
|
|
44
|
+
];
|
|
45
|
+
const resolveCollisionsSpatial = (nodes, minDistance) => {
|
|
46
|
+
const gridCellSize = minDistance * 1.05;
|
|
47
|
+
const grid = buildCollisionGrid(nodes, gridCellSize);
|
|
48
|
+
let moved = false;
|
|
49
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
50
|
+
const left = nodes[index];
|
|
51
|
+
const leftCellX = Math.floor(left.x / gridCellSize);
|
|
52
|
+
const leftCellY = Math.floor(left.y / gridCellSize);
|
|
53
|
+
neighborCellKeys(leftCellX, leftCellY).forEach((key) => {
|
|
54
|
+
const candidateIndices = grid.get(key) ?? [];
|
|
55
|
+
candidateIndices.forEach((candidateIndex) => {
|
|
56
|
+
if (candidateIndex <= index) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
moved = resolveCollisionPair(left, nodes[candidateIndex], minDistance) || moved;
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return moved;
|
|
64
|
+
};
|
|
65
|
+
const resolveCollisionsBrute = (nodes, minDistance) => {
|
|
66
|
+
let moved = false;
|
|
67
|
+
for (let leftIndex = 0; leftIndex < nodes.length; leftIndex += 1) {
|
|
68
|
+
const left = nodes[leftIndex];
|
|
69
|
+
for (let rightIndex = leftIndex + 1; rightIndex < nodes.length; rightIndex += 1) {
|
|
70
|
+
moved = resolveCollisionPair(left, nodes[rightIndex], minDistance) || moved;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return moved;
|
|
74
|
+
};
|
|
75
|
+
export const relaxCollisions = (nodes, minDistance = 132, rounds = 32) => {
|
|
76
|
+
if (nodes.length <= 1) {
|
|
77
|
+
return nodes;
|
|
78
|
+
}
|
|
79
|
+
const effectiveRounds = nodes.length > 1000
|
|
80
|
+
? Math.min(rounds, 12)
|
|
81
|
+
: nodes.length > 500
|
|
82
|
+
? Math.min(rounds, 20)
|
|
83
|
+
: Math.min(rounds, 26);
|
|
84
|
+
const layoutNodes = nodes.map((node) => ({
|
|
85
|
+
...node,
|
|
86
|
+
x: Number.isFinite(node.x) ? node.x : 0,
|
|
87
|
+
y: Number.isFinite(node.y) ? node.y : 0
|
|
88
|
+
}));
|
|
89
|
+
for (let round = 0; round < effectiveRounds; round += 1) {
|
|
90
|
+
const moved = nodes.length <= 250
|
|
91
|
+
? resolveCollisionsBrute(layoutNodes, minDistance)
|
|
92
|
+
: resolveCollisionsSpatial(layoutNodes, minDistance);
|
|
93
|
+
// Once a full round resolves no overlap the layout is collision-free and
|
|
94
|
+
// further rounds are no-ops, so the result is identical with fewer rounds.
|
|
95
|
+
if (!moved) {
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return layoutNodes;
|
|
100
|
+
};
|