@aabadin/project-memory-context 0.2.5 → 0.2.7
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/bin/pmc-view-context.mjs +19 -0
- package/cli/bootstrap.mjs +6 -44
- package/package.json +5 -2
- package/src/command-dispatch.mjs +1 -0
- package/src/extractors/structure-extractor.mjs +14 -1
- package/src/template-installer.mjs +1 -0
- package/templates/opencode/commands/view-context.md +21 -0
- package/tools/pmc-graph-explorer/public/app.js +94 -0
- package/tools/pmc-graph-explorer/public/context-tracker.js +18 -0
- package/tools/pmc-graph-explorer/public/filters.js +48 -0
- package/tools/pmc-graph-explorer/public/graph.js +187 -0
- package/tools/pmc-graph-explorer/public/index.html +39 -0
- package/tools/pmc-graph-explorer/public/sidebar.js +85 -0
- package/tools/pmc-graph-explorer/public/styles.css +264 -0
- package/tools/pmc-graph-explorer/server.mjs +76 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const PACKAGE_ROOT = resolve(__dirname, '..');
|
|
8
|
+
|
|
9
|
+
// graph-explorer lives INSIDE the package: tools/pmc-graph-explorer/
|
|
10
|
+
const GRAPH_EXPLORER_PATH = resolve(PACKAGE_ROOT, 'tools/pmc-graph-explorer/server.mjs');
|
|
11
|
+
|
|
12
|
+
const child = spawn(process.execPath, [GRAPH_EXPLORER_PATH], {
|
|
13
|
+
cwd: process.cwd(),
|
|
14
|
+
stdio: 'inherit',
|
|
15
|
+
detached: true,
|
|
16
|
+
shell: true,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
child.unref();
|
package/cli/bootstrap.mjs
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
import { resolve, dirname } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { access, constants, readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
5
|
-
import { existsSync, readdirSync,
|
|
5
|
+
import { existsSync, readdirSync, copyFileSync } from 'node:fs';
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
7
|
|
|
8
8
|
import { bootstrapProjectInstall } from '../src/setup-bootstrap.mjs';
|
|
9
9
|
import { resolveGraphify, spawnBackground, resolvePythonBin } from '../src/platform.mjs';
|
|
10
|
+
import { installPmcTools } from './install-pmc.mjs';
|
|
10
11
|
|
|
11
12
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
13
|
const PMC_CLI_ROOT = resolve(__dirname);
|
|
13
|
-
const PMC_ROOT = resolve(__dirname, '..', '..', '..');
|
|
14
14
|
const PMC_PACKAGE_ROOT = resolve(__dirname, '..');
|
|
15
15
|
|
|
16
16
|
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
|
|
@@ -94,46 +94,8 @@ async function installGraphify() {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
async function syncToolsToTarget(projectRoot) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const dstCli = resolve(projectRoot, 'tools', 'project-memory-context', 'cli');
|
|
100
|
-
|
|
101
|
-
mkdirSync(resolve(projectRoot, 'tools', 'project-memory-context'), { recursive: true });
|
|
102
|
-
mkdirSync(dstCli, { recursive: true });
|
|
103
|
-
|
|
104
|
-
const files = ['new-project.mjs', 'bootstrap.mjs', 'enrich-queue.mjs', 'build-worklist.mjs', 'project-context.mjs', 'sanitize.mjs'];
|
|
105
|
-
for (const f of files) {
|
|
106
|
-
try {
|
|
107
|
-
copyFileSync(resolve(srcCli, f), resolve(dstCli, f));
|
|
108
|
-
log(` copied ${f}`);
|
|
109
|
-
} catch {
|
|
110
|
-
log(` skipped ${f} (not found in source)`);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const srcSrc = resolve(PMC_ROOT, 'tools', 'project-memory-context', 'src');
|
|
115
|
-
const dstSrc = resolve(projectRoot, 'tools', 'project-memory-context', 'src');
|
|
116
|
-
mkdirSync(dstSrc, { recursive: true });
|
|
117
|
-
|
|
118
|
-
function copyTree(srcDir, dstDir) {
|
|
119
|
-
mkdirSync(dstDir, { recursive: true });
|
|
120
|
-
const entries = readdirSync(srcDir, { withFileTypes: true });
|
|
121
|
-
for (const entry of entries) {
|
|
122
|
-
const srcPath = resolve(srcDir, entry.name);
|
|
123
|
-
const dstPath = resolve(dstDir, entry.name);
|
|
124
|
-
if (entry.isDirectory()) {
|
|
125
|
-
copyTree(srcPath, dstPath);
|
|
126
|
-
} else if (entry.isFile() && entry.name.endsWith('.mjs')) {
|
|
127
|
-
copyFileSync(srcPath, dstPath);
|
|
128
|
-
log(` copied ${dstPath.replace(`${projectRoot}\\`, '').replace(/\\/g, '/')}`);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
try {
|
|
134
|
-
copyTree(srcSrc, dstSrc);
|
|
135
|
-
} catch { log(' src/ copy skipped (may not exist)'); }
|
|
136
|
-
|
|
97
|
+
const result = installPmcTools({ sourceRoot: PMC_PACKAGE_ROOT, targetRoot: projectRoot });
|
|
98
|
+
log(` copied ${result.cliFiles} CLI files, ${result.srcFiles} src files, ${result.templateFiles} templates`);
|
|
137
99
|
log(' PMC tools synced to target repo.');
|
|
138
100
|
}
|
|
139
101
|
|
|
@@ -199,7 +161,7 @@ function findFiles(dir, exts, ignore, projectRoot) {
|
|
|
199
161
|
async function runStageB(projectRoot) {
|
|
200
162
|
log('Running stage-b (build-worklist)...');
|
|
201
163
|
|
|
202
|
-
const ignore = ['node_modules', 'dist', '.git', 'bin', 'obj', '.opencode', '.planning'];
|
|
164
|
+
const ignore = ['node_modules', 'dist', '.git', 'bin', 'obj', '.opencode', '.planning', '.next'];
|
|
203
165
|
const files = [
|
|
204
166
|
...findFiles(projectRoot, ['.ts', '.mjs', '.js'], ignore, projectRoot),
|
|
205
167
|
...findFiles(projectRoot, ['.cs'], ignore, projectRoot),
|
|
@@ -281,7 +243,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
281
243
|
const runEnrichFlag = args.includes('--enrich');
|
|
282
244
|
|
|
283
245
|
log(`Target repo: ${targetDir}`);
|
|
284
|
-
log(`PMC package root: ${
|
|
246
|
+
log(`PMC package root: ${PMC_PACKAGE_ROOT}`);
|
|
285
247
|
log(`Ollama: ${OLLAMA_URL} | Model: ${OLLAMA_MODEL}`);
|
|
286
248
|
|
|
287
249
|
if (!await ensureDir(targetDir)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aabadin/project-memory-context",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "Portable project memory context CLI — bootstraps semantic enrichment workflows for any AI coding agent.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"type": "module",
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
},
|
|
13
13
|
"bin": {
|
|
14
14
|
"pmc": "bin/pmc.mjs",
|
|
15
|
-
"pmc-query-server": "mcp/pmc-query-server.mjs"
|
|
15
|
+
"pmc-query-server": "mcp/pmc-query-server.mjs",
|
|
16
|
+
"pmc-view-context": "bin/pmc-view-context.mjs"
|
|
16
17
|
},
|
|
17
18
|
"scripts": {
|
|
18
19
|
"test": "node --test tests/*.test.mjs",
|
|
@@ -26,6 +27,7 @@
|
|
|
26
27
|
"plugin/",
|
|
27
28
|
"src/",
|
|
28
29
|
"templates/",
|
|
30
|
+
"tools/pmc-graph-explorer/",
|
|
29
31
|
"README.md",
|
|
30
32
|
"LICENSE"
|
|
31
33
|
],
|
|
@@ -56,6 +58,7 @@
|
|
|
56
58
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
57
59
|
"acorn": "^8.16.0",
|
|
58
60
|
"acorn-walk": "^8.3.0",
|
|
61
|
+
"express": "^5.2.1",
|
|
59
62
|
"zod": "^3.24.0"
|
|
60
63
|
}
|
|
61
64
|
}
|
package/src/command-dispatch.mjs
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import { readdir, stat } from 'node:fs/promises';
|
|
2
2
|
import { join, relative } from 'node:path';
|
|
3
3
|
|
|
4
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
5
|
+
'node_modules',
|
|
6
|
+
'.git',
|
|
7
|
+
'.planning',
|
|
8
|
+
'.next',
|
|
9
|
+
'graphify-out',
|
|
10
|
+
'dist',
|
|
11
|
+
'build',
|
|
12
|
+
'coverage',
|
|
13
|
+
'.turbo',
|
|
14
|
+
'.vercel',
|
|
15
|
+
]);
|
|
16
|
+
|
|
4
17
|
async function listDirectories(dir, root, depth = 0, maxDepth = 2, acc = []) {
|
|
5
18
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
6
19
|
for (const entry of entries) {
|
|
7
20
|
if (!entry.isDirectory()) continue;
|
|
8
|
-
if (
|
|
21
|
+
if (IGNORED_DIRECTORIES.has(entry.name)) continue;
|
|
9
22
|
const full = join(dir, entry.name);
|
|
10
23
|
const rel = relative(root, full).replace(/\\/g, '/');
|
|
11
24
|
acc.push({ rel, depth });
|
|
@@ -109,6 +109,7 @@ async function installOpencode({ projectRoot, packageRoot, placeholders, globalC
|
|
|
109
109
|
'opencode/commands/doctor.md',
|
|
110
110
|
'opencode/commands/init-project.md',
|
|
111
111
|
'opencode/commands/retry-errors.md',
|
|
112
|
+
'opencode/commands/view-context.md',
|
|
112
113
|
];
|
|
113
114
|
|
|
114
115
|
for (const tpl of commandTemplates) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: view-context
|
|
3
|
+
description: Open the PMC Graph Explorer web UI to visualize the enrichment graph with active context highlighting.
|
|
4
|
+
argument-hint: ""
|
|
5
|
+
allowed-tools:
|
|
6
|
+
- Bash
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<objective>
|
|
10
|
+
Open the PMC Graph Explorer to visualize the enrichment graph. The server runs on port 3001 and shows nodes consulted via /get-context with a cyan glow.
|
|
11
|
+
</objective>
|
|
12
|
+
|
|
13
|
+
<execution>
|
|
14
|
+
Start the graph explorer server using the globally installed PMC CLI:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx --yes @aabadin/project-memory-context pmc-view-context
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then open http://localhost:3001 in your browser.
|
|
21
|
+
</execution>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { createGraph } from "./graph.js";
|
|
2
|
+
import { updateSidebar } from "./sidebar.js";
|
|
3
|
+
import { initFilters, updateStats } from "./filters.js";
|
|
4
|
+
import { loadContext } from "./context-tracker.js";
|
|
5
|
+
|
|
6
|
+
const state = {
|
|
7
|
+
graphData: null,
|
|
8
|
+
worklistData: null,
|
|
9
|
+
contextData: null,
|
|
10
|
+
selectedNode: null,
|
|
11
|
+
activeOnly: false,
|
|
12
|
+
searchQuery: "",
|
|
13
|
+
enabledCommunities: new Set(),
|
|
14
|
+
enabledTypes: new Set(["file", "class", "method", "function", "interface"]),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
async function loadData() {
|
|
18
|
+
const [graphRes, worklistRes, contextRes] = await Promise.all([
|
|
19
|
+
fetch("/api/graph"),
|
|
20
|
+
fetch("/api/worklist"),
|
|
21
|
+
fetch("/api/context"),
|
|
22
|
+
]);
|
|
23
|
+
state.graphData = await graphRes.json();
|
|
24
|
+
state.worklistData = await worklistRes.json();
|
|
25
|
+
state.contextData = await contextRes.json();
|
|
26
|
+
return state;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getActiveNodeIds() {
|
|
30
|
+
if (!state.contextData || !Array.isArray(state.contextData.activeNodeIds)) {
|
|
31
|
+
return new Set();
|
|
32
|
+
}
|
|
33
|
+
return new Set(state.contextData.activeNodeIds);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getEnrichedNodeIds() {
|
|
37
|
+
if (!state.worklistData) return new Set();
|
|
38
|
+
return new Set(state.worklistData.map((e) => e.graphNodeId));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getWorklistStatusMap() {
|
|
42
|
+
if (!state.worklistData) return new Map();
|
|
43
|
+
const m = new Map();
|
|
44
|
+
state.worklistData.forEach((e) => m.set(e.graphNodeId, e.status));
|
|
45
|
+
return m;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function init() {
|
|
49
|
+
await loadData();
|
|
50
|
+
|
|
51
|
+
const communities = [
|
|
52
|
+
...new Set(state.graphData.nodes.map((n) => n.community)),
|
|
53
|
+
].sort((a, b) => a - b);
|
|
54
|
+
communities.forEach((c) => state.enabledCommunities.add(c));
|
|
55
|
+
|
|
56
|
+
state.getEnrichedNodeIds = getEnrichedNodeIds;
|
|
57
|
+
state.getWorklistStatusMap = getWorklistStatusMap;
|
|
58
|
+
|
|
59
|
+
initFilters(state, communities);
|
|
60
|
+
updateStats(state);
|
|
61
|
+
createGraph(state, {
|
|
62
|
+
onNodeClick: (node) => {
|
|
63
|
+
state.selectedNode = node;
|
|
64
|
+
updateSidebar(node, state);
|
|
65
|
+
},
|
|
66
|
+
getActiveNodeIds,
|
|
67
|
+
getEnrichedNodeIds,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
document.getElementById("search").addEventListener("input", (e) => {
|
|
72
|
+
state.searchQuery = e.target.value.toLowerCase();
|
|
73
|
+
document.dispatchEvent(new CustomEvent("graph-update", { detail: state }));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
document.getElementById("toggle-active").addEventListener("click", (e) => {
|
|
77
|
+
state.activeOnly = !state.activeOnly;
|
|
78
|
+
e.target.classList.toggle("active", state.activeOnly);
|
|
79
|
+
document.dispatchEvent(new CustomEvent("graph-update", { detail: state }));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
document.getElementById("toggle-panel").addEventListener("click", () => {
|
|
83
|
+
document.getElementById("sidebar").classList.toggle("collapsed");
|
|
84
|
+
document.dispatchEvent(new CustomEvent("graph-resize"));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
document.addEventListener("context-changed", async () => {
|
|
88
|
+
const res = await fetch("/api/context");
|
|
89
|
+
state.contextData = await res.json();
|
|
90
|
+
document.dispatchEvent(new CustomEvent("graph-update", { detail: state }));
|
|
91
|
+
updateStats(state);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
init();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export async function loadContext() {
|
|
2
|
+
try {
|
|
3
|
+
const res = await fetch("/api/context");
|
|
4
|
+
if (!res.ok) return { activeNodeIds: [] };
|
|
5
|
+
return await res.json();
|
|
6
|
+
} catch {
|
|
7
|
+
return { activeNodeIds: [] };
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function markNodesActive(nodeIds) {
|
|
12
|
+
await fetch("/api/context", {
|
|
13
|
+
method: "POST",
|
|
14
|
+
headers: { "Content-Type": "application/json" },
|
|
15
|
+
body: JSON.stringify({ add: nodeIds }),
|
|
16
|
+
});
|
|
17
|
+
document.dispatchEvent(new CustomEvent("context-changed"));
|
|
18
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const COMMUNITY_COLORS_MAP = d3.scaleOrdinal(d3.schemeTableau10);
|
|
2
|
+
|
|
3
|
+
export function initFilters(state, communities) {
|
|
4
|
+
const container = document.getElementById("filters");
|
|
5
|
+
|
|
6
|
+
const communitySection = document.createElement("div");
|
|
7
|
+
communitySection.innerHTML = `<h3>Communities (${communities.length})</h3>`;
|
|
8
|
+
|
|
9
|
+
communities.forEach((c) => {
|
|
10
|
+
const row = document.createElement("label");
|
|
11
|
+
row.className = "filter-row";
|
|
12
|
+
row.innerHTML = `<input type="checkbox" checked data-community="${c}"><span class="filter-dot" style="background:${COMMUNITY_COLORS_MAP(c)}"></span>Community ${c}`;
|
|
13
|
+
row.querySelector("input").addEventListener("change", (e) => {
|
|
14
|
+
if (e.target.checked) {
|
|
15
|
+
state.enabledCommunities.add(c);
|
|
16
|
+
} else {
|
|
17
|
+
state.enabledCommunities.delete(c);
|
|
18
|
+
}
|
|
19
|
+
document.dispatchEvent(new CustomEvent("graph-update", { detail: state }));
|
|
20
|
+
updateStats(state);
|
|
21
|
+
});
|
|
22
|
+
communitySection.appendChild(row);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
container.appendChild(communitySection);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function updateStats(state) {
|
|
29
|
+
const statsEl = document.getElementById("stats");
|
|
30
|
+
const nodes = state.graphData?.nodes || [];
|
|
31
|
+
const activeIds = new Set(state.contextData?.activeNodeIds || []);
|
|
32
|
+
const enrichedIds = state.getEnrichedNodeIds ? state.getEnrichedNodeIds() : new Set();
|
|
33
|
+
const activeCount = nodes.filter((n) => activeIds.has(n.id)).length;
|
|
34
|
+
const enrichedCount = nodes.filter((n) => enrichedIds.has(n.id)).length;
|
|
35
|
+
const visibleCount = nodes.filter((n) => {
|
|
36
|
+
const matchCommunity = state.enabledCommunities.has(n.community);
|
|
37
|
+
const matchActive = !state.activeOnly || activeIds.has(n.id);
|
|
38
|
+
const matchSearch = !state.searchQuery || n.label.toLowerCase().includes(state.searchQuery);
|
|
39
|
+
return matchCommunity && matchActive && matchSearch;
|
|
40
|
+
}).length;
|
|
41
|
+
|
|
42
|
+
statsEl.innerHTML = `
|
|
43
|
+
<span class="stat-badge">Nodes: ${nodes.length}</span>
|
|
44
|
+
<span class="stat-badge">Visible: ${visibleCount}</span>
|
|
45
|
+
<span class="stat-badge">Enriched: ${enrichedCount}</span>
|
|
46
|
+
${activeCount > 0 ? `<span class="stat-badge active">Active: ${activeCount}</span>` : ""}
|
|
47
|
+
`;
|
|
48
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
const COMMUNITY_COLORS = d3.scaleOrdinal(d3.schemeTableau10);
|
|
2
|
+
const EDGE_RELATION_THICKNESS = {
|
|
3
|
+
imports_from: 2,
|
|
4
|
+
imports: 1.5,
|
|
5
|
+
contains: 1,
|
|
6
|
+
method: 1,
|
|
7
|
+
calls: 1.5,
|
|
8
|
+
inherits: 2,
|
|
9
|
+
case_of: 1,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function nodeRadius(node) {
|
|
13
|
+
const label = (node.label || "").toLowerCase();
|
|
14
|
+
if (/\.(ts|js|mjs|json)$/.test(label)) return 12;
|
|
15
|
+
if (label.includes("(")) return 5;
|
|
16
|
+
return 8;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createGraph(state, callbacks) {
|
|
20
|
+
const container = document.getElementById("canvas");
|
|
21
|
+
const svg = d3.select("#graph-svg");
|
|
22
|
+
const tooltip = document.getElementById("tooltip");
|
|
23
|
+
const width = container.clientWidth;
|
|
24
|
+
const height = container.clientHeight;
|
|
25
|
+
|
|
26
|
+
svg.attr("viewBox", [0, 0, width, height]);
|
|
27
|
+
|
|
28
|
+
const g = svg.append("g");
|
|
29
|
+
|
|
30
|
+
const zoom = d3.zoom()
|
|
31
|
+
.scaleExtent([0.1, 8])
|
|
32
|
+
.on("zoom", (event) => {
|
|
33
|
+
g.attr("transform", event.transform);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
svg.call(zoom);
|
|
37
|
+
|
|
38
|
+
const { nodes, links } = state.graphData;
|
|
39
|
+
const activeIds = callbacks.getActiveNodeIds();
|
|
40
|
+
const enrichedIds = callbacks.getEnrichedNodeIds();
|
|
41
|
+
|
|
42
|
+
const defs = svg.append("defs");
|
|
43
|
+
const filter = defs.append("filter").attr("id", "glow");
|
|
44
|
+
filter.append("feGaussianBlur").attr("stdDeviation", "6").attr("result", "blur");
|
|
45
|
+
filter.append("feMerge").selectAll("feMergeNode")
|
|
46
|
+
.data(["blur", "SourceGraphic"])
|
|
47
|
+
.join("feMergeNode")
|
|
48
|
+
.attr("in", (d) => d);
|
|
49
|
+
|
|
50
|
+
const linkElements = g.append("g")
|
|
51
|
+
.attr("class", "links")
|
|
52
|
+
.selectAll("line")
|
|
53
|
+
.data(links)
|
|
54
|
+
.join("line")
|
|
55
|
+
.attr("stroke", "#475569")
|
|
56
|
+
.attr("stroke-opacity", 0.15)
|
|
57
|
+
.attr("stroke-width", (d) => EDGE_RELATION_THICKNESS[d.relation] || 1);
|
|
58
|
+
|
|
59
|
+
const nodeGroups = g.append("g")
|
|
60
|
+
.attr("class", "nodes")
|
|
61
|
+
.selectAll("g")
|
|
62
|
+
.data(nodes)
|
|
63
|
+
.join("g")
|
|
64
|
+
.attr("class", "node-group")
|
|
65
|
+
.style("cursor", "pointer");
|
|
66
|
+
|
|
67
|
+
nodeGroups.filter((d) => activeIds.has(d.id))
|
|
68
|
+
.append("circle")
|
|
69
|
+
.attr("r", (d) => nodeRadius(d) + 6)
|
|
70
|
+
.attr("fill", "none")
|
|
71
|
+
.attr("stroke", "#06b6d4")
|
|
72
|
+
.attr("stroke-width", 2)
|
|
73
|
+
.attr("filter", "url(#glow)")
|
|
74
|
+
.attr("class", "active-halo");
|
|
75
|
+
|
|
76
|
+
nodeGroups.append("circle")
|
|
77
|
+
.attr("r", (d) => nodeRadius(d))
|
|
78
|
+
.attr("fill", (d) => COMMUNITY_COLORS(d.community))
|
|
79
|
+
.attr("fill-opacity", (d) => activeIds.has(d.id) ? 1 : 0.7)
|
|
80
|
+
.attr("stroke", (d) => activeIds.has(d.id) ? "#06b6d4" : "#1e293b")
|
|
81
|
+
.attr("stroke-width", (d) => activeIds.has(d.id) ? 2 : 1);
|
|
82
|
+
|
|
83
|
+
const simulation = d3.forceSimulation(nodes)
|
|
84
|
+
.force("link", d3.forceLink(links).id((d) => d.id).distance(40).strength(0.5))
|
|
85
|
+
.force("charge", d3.forceManyBody().strength(-80))
|
|
86
|
+
.force("center", d3.forceCenter(width / 2, height / 2))
|
|
87
|
+
.force("collide", d3.forceCollide().radius((d) => nodeRadius(d) + 4))
|
|
88
|
+
.alphaDecay(0.02)
|
|
89
|
+
.on("tick", ticked);
|
|
90
|
+
|
|
91
|
+
function ticked() {
|
|
92
|
+
linkElements
|
|
93
|
+
.attr("x1", (d) => d.source.x)
|
|
94
|
+
.attr("y1", (d) => d.source.y)
|
|
95
|
+
.attr("x2", (d) => d.target.x)
|
|
96
|
+
.attr("y2", (d) => d.target.y);
|
|
97
|
+
|
|
98
|
+
nodeGroups.attr("transform", (d) => `translate(${d.x},${d.y})`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const drag = d3.drag()
|
|
102
|
+
.on("start", (event, d) => {
|
|
103
|
+
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
104
|
+
d.fx = d.x;
|
|
105
|
+
d.fy = d.y;
|
|
106
|
+
})
|
|
107
|
+
.on("drag", (event, d) => {
|
|
108
|
+
d.fx = event.x;
|
|
109
|
+
d.fy = event.y;
|
|
110
|
+
})
|
|
111
|
+
.on("end", (event, d) => {
|
|
112
|
+
if (!event.active) simulation.alphaTarget(0);
|
|
113
|
+
d.fx = null;
|
|
114
|
+
d.fy = null;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
nodeGroups.call(drag);
|
|
118
|
+
|
|
119
|
+
nodeGroups
|
|
120
|
+
.on("mouseover", (event, d) => {
|
|
121
|
+
const status = enrichedIds.has(d.id) ? "enriched" : "not enriched";
|
|
122
|
+
const active = activeIds.has(d.id) ? " | IN CONTEXT" : "";
|
|
123
|
+
tooltip.innerHTML = `<strong>${d.label}</strong><br><code>${d.source_file}:${d.source_location}</code><br>Community ${d.community} | ${status}${active}`;
|
|
124
|
+
tooltip.classList.add("visible");
|
|
125
|
+
tooltip.style.left = `${event.offsetX + 12}px`;
|
|
126
|
+
tooltip.style.top = `${event.offsetY - 8}px`;
|
|
127
|
+
})
|
|
128
|
+
.on("mouseout", () => {
|
|
129
|
+
tooltip.classList.remove("visible");
|
|
130
|
+
})
|
|
131
|
+
.on("click", (event, d) => {
|
|
132
|
+
event.stopPropagation();
|
|
133
|
+
callbacks.onNodeClick(d);
|
|
134
|
+
})
|
|
135
|
+
.on("dblclick", (event, d) => {
|
|
136
|
+
event.stopPropagation();
|
|
137
|
+
const scale = 2;
|
|
138
|
+
const transform = d3.zoomIdentity
|
|
139
|
+
.translate(width / 2 - d.x * scale, height / 2 - d.y * scale)
|
|
140
|
+
.scale(scale);
|
|
141
|
+
svg.transition().duration(400).call(zoom.transform, transform);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
document.addEventListener("graph-update", (e) => {
|
|
145
|
+
applyFilters(e.detail, nodeGroups, linkElements, simulation);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
document.addEventListener("graph-resize", () => {
|
|
149
|
+
const w = container.clientWidth;
|
|
150
|
+
const h = container.clientHeight;
|
|
151
|
+
svg.attr("viewBox", [0, 0, w, h]);
|
|
152
|
+
simulation.force("center", d3.forceCenter(w / 2, h / 2));
|
|
153
|
+
simulation.alpha(0.3).restart();
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function applyFilters(state, nodeGroups, linkElements, simulation) {
|
|
158
|
+
const activeIds = new Set(
|
|
159
|
+
state.contextData?.activeNodeIds || []
|
|
160
|
+
);
|
|
161
|
+
const q = state.searchQuery;
|
|
162
|
+
|
|
163
|
+
nodeGroups.style("display", (d) => {
|
|
164
|
+
const matchSearch = !q || d.label.toLowerCase().includes(q) || d.source_file.toLowerCase().includes(q);
|
|
165
|
+
const matchCommunity = state.enabledCommunities.has(d.community);
|
|
166
|
+
const kind = inferKind(d.label);
|
|
167
|
+
const matchType = state.enabledTypes.has(kind);
|
|
168
|
+
const matchActive = !state.activeOnly || activeIds.has(d.id);
|
|
169
|
+
return matchSearch && matchCommunity && matchType && matchActive ? null : "none";
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (q) {
|
|
173
|
+
nodeGroups.select("circle:not(.active-halo)")
|
|
174
|
+
.attr("fill-opacity", (d) => d.label.toLowerCase().includes(q) ? 1 : 0.15);
|
|
175
|
+
} else {
|
|
176
|
+
nodeGroups.select("circle:not(.active-halo)")
|
|
177
|
+
.attr("fill-opacity", (d) => activeIds.has(d.id) ? 1 : 0.7);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
simulation.alpha(0.1).restart();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function inferKind(label) {
|
|
184
|
+
if (/\.(ts|js|mjs|json)$/.test(label)) return "file";
|
|
185
|
+
if (label.includes("(")) return "method";
|
|
186
|
+
return "class";
|
|
187
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>PMC Graph Explorer</title>
|
|
7
|
+
<link rel="stylesheet" href="styles.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<header class="header">
|
|
11
|
+
<div class="header-left">
|
|
12
|
+
<h1 class="header-title">PMC Graph Explorer</h1>
|
|
13
|
+
<div class="stats" id="stats"></div>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="header-right">
|
|
16
|
+
<input type="text" id="search" class="search-input" placeholder="Search nodes..." autocomplete="off">
|
|
17
|
+
<button id="toggle-active" class="btn-toggle" title="Show only active context">Active only</button>
|
|
18
|
+
<button id="toggle-panel" class="btn-toggle" title="Toggle side panel">☰</button>
|
|
19
|
+
</div>
|
|
20
|
+
</header>
|
|
21
|
+
|
|
22
|
+
<div class="main">
|
|
23
|
+
<aside class="sidebar" id="sidebar">
|
|
24
|
+
<div class="sidebar-content" id="sidebar-content">
|
|
25
|
+
<p class="sidebar-empty">Click a node to see details</p>
|
|
26
|
+
</div>
|
|
27
|
+
<div class="sidebar-filters" id="filters"></div>
|
|
28
|
+
</aside>
|
|
29
|
+
|
|
30
|
+
<div class="canvas" id="canvas">
|
|
31
|
+
<svg id="graph-svg"></svg>
|
|
32
|
+
<div class="tooltip" id="tooltip"></div>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
37
|
+
<script type="module" src="app.js"></script>
|
|
38
|
+
</body>
|
|
39
|
+
</html>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
const COMMUNITY_COLORS_MAP = d3.scaleOrdinal(d3.schemeTableau10);
|
|
2
|
+
|
|
3
|
+
export function updateSidebar(node, state) {
|
|
4
|
+
const container = document.getElementById("sidebar-content");
|
|
5
|
+
if (!node) {
|
|
6
|
+
container.innerHTML = '<p class="sidebar-empty">Click a node to see details</p>';
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const activeIds = new Set(state.contextData?.activeNodeIds || []);
|
|
11
|
+
const isActive = activeIds.has(node.id);
|
|
12
|
+
const worklistMap = state.getWorklistStatusMap ? state.getWorklistStatusMap() : new Map();
|
|
13
|
+
const enrichStatus = worklistMap.get(node.id) || "unknown";
|
|
14
|
+
|
|
15
|
+
const inLinks = state.graphData.links.filter((l) => l.target === node.id || (l.target && l.target.id === node.id));
|
|
16
|
+
const outLinks = state.graphData.links.filter((l) => l.source === node.id || (l.source && l.source.id === node.id));
|
|
17
|
+
|
|
18
|
+
const kind = inferKind(node.label);
|
|
19
|
+
|
|
20
|
+
container.innerHTML = `
|
|
21
|
+
<div class="node-detail">
|
|
22
|
+
<div class="node-detail-label">Name</div>
|
|
23
|
+
<div class="node-detail-value">${escapeHtml(node.label)}</div>
|
|
24
|
+
|
|
25
|
+
<div class="node-detail-label">Kind</div>
|
|
26
|
+
<div class="node-detail-value"><span class="badge" style="background:var(--bg-elevated)">${kind}</span></div>
|
|
27
|
+
|
|
28
|
+
<div class="node-detail-label">Source</div>
|
|
29
|
+
<div class="node-detail-value"><code>${escapeHtml(node.source_file || "")}:${escapeHtml(node.source_location || "")}</code></div>
|
|
30
|
+
|
|
31
|
+
<div class="node-detail-label">Community</div>
|
|
32
|
+
<div class="node-detail-value">
|
|
33
|
+
<span class="badge badge-community" style="background:${COMMUNITY_COLORS_MAP(node.community)}">Community ${node.community}</span>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div class="node-detail-label">Enrichment</div>
|
|
37
|
+
<div class="node-detail-value">
|
|
38
|
+
<span class="badge" style="background:var(--bg-elevated)">${enrichStatus}</span>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
${isActive ? '<div class="node-detail-value"><span class="badge badge-context">EN CONTEXTO</span></div>' : ""}
|
|
42
|
+
|
|
43
|
+
<div class="node-detail-label">Incoming (${inLinks.length})</div>
|
|
44
|
+
<ul class="relations-list">
|
|
45
|
+
${inLinks.map((l) => {
|
|
46
|
+
const srcId = typeof l.source === "object" ? l.source.id : l.source;
|
|
47
|
+
const srcNode = state.graphData.nodes.find((n) => n.id === srcId);
|
|
48
|
+
return `<li data-node-id="${srcId}"><span class="rel-type">${escapeHtml(l.relation)}</span>${srcNode ? escapeHtml(srcNode.label) : srcId}</li>`;
|
|
49
|
+
}).join("")}
|
|
50
|
+
</ul>
|
|
51
|
+
|
|
52
|
+
<div class="node-detail-label">Outgoing (${outLinks.length})</div>
|
|
53
|
+
<ul class="relations-list">
|
|
54
|
+
${outLinks.map((l) => {
|
|
55
|
+
const tgtId = typeof l.target === "object" ? l.target.id : l.target;
|
|
56
|
+
const tgtNode = state.graphData.nodes.find((n) => n.id === tgtId);
|
|
57
|
+
return `<li data-node-id="${tgtId}"><span class="rel-type">${escapeHtml(l.relation)}</span>${tgtNode ? escapeHtml(tgtNode.label) : tgtId}</li>`;
|
|
58
|
+
}).join("")}
|
|
59
|
+
</ul>
|
|
60
|
+
</div>
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
container.querySelectorAll("[data-node-id]").forEach((el) => {
|
|
64
|
+
el.addEventListener("click", () => {
|
|
65
|
+
const targetId = el.getAttribute("data-node-id");
|
|
66
|
+
const targetNode = state.graphData.nodes.find((n) => n.id === targetId);
|
|
67
|
+
if (targetNode) {
|
|
68
|
+
state.selectedNode = targetNode;
|
|
69
|
+
updateSidebar(targetNode, state);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function inferKind(label) {
|
|
76
|
+
if (/\.(ts|js|mjs|json)$/.test(label)) return "file";
|
|
77
|
+
if (label.includes("(")) return "method";
|
|
78
|
+
return "class";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function escapeHtml(str) {
|
|
82
|
+
const d = document.createElement("div");
|
|
83
|
+
d.textContent = str;
|
|
84
|
+
return d.innerHTML;
|
|
85
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg-primary: #0f172a;
|
|
3
|
+
--bg-surface: #1e293b;
|
|
4
|
+
--bg-elevated: #334155;
|
|
5
|
+
--text-primary: #e2e8f0;
|
|
6
|
+
--text-secondary: #94a3b8;
|
|
7
|
+
--accent: #06b6d4;
|
|
8
|
+
--accent-dim: #0891b2;
|
|
9
|
+
--border: #475569;
|
|
10
|
+
--radius: 6px;
|
|
11
|
+
--header-h: 56px;
|
|
12
|
+
--sidebar-w: 280px;
|
|
13
|
+
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
|
|
14
|
+
--font-mono: "JetBrains Mono", "Fira Code", monospace;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
18
|
+
|
|
19
|
+
body {
|
|
20
|
+
font-family: var(--font-sans);
|
|
21
|
+
font-size: 14px;
|
|
22
|
+
color: var(--text-primary);
|
|
23
|
+
background: var(--bg-primary);
|
|
24
|
+
height: 100vh;
|
|
25
|
+
overflow: hidden;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.header {
|
|
29
|
+
height: var(--header-h);
|
|
30
|
+
background: var(--bg-surface);
|
|
31
|
+
border-bottom: 1px solid var(--border);
|
|
32
|
+
display: flex;
|
|
33
|
+
align-items: center;
|
|
34
|
+
justify-content: space-between;
|
|
35
|
+
padding: 0 16px;
|
|
36
|
+
z-index: 10;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.header-left { display: flex; align-items: center; gap: 16px; }
|
|
40
|
+
|
|
41
|
+
.header-title {
|
|
42
|
+
font-size: 18px;
|
|
43
|
+
font-weight: 600;
|
|
44
|
+
white-space: nowrap;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.stats {
|
|
48
|
+
display: flex;
|
|
49
|
+
gap: 12px;
|
|
50
|
+
font-size: 12px;
|
|
51
|
+
color: var(--text-secondary);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.stat-badge {
|
|
55
|
+
background: var(--bg-elevated);
|
|
56
|
+
padding: 2px 8px;
|
|
57
|
+
border-radius: var(--radius);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.stat-badge.active {
|
|
61
|
+
background: var(--accent);
|
|
62
|
+
color: var(--bg-primary);
|
|
63
|
+
font-weight: 600;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.header-right { display: flex; align-items: center; gap: 8px; }
|
|
67
|
+
|
|
68
|
+
.search-input {
|
|
69
|
+
background: var(--bg-elevated);
|
|
70
|
+
border: 1px solid var(--border);
|
|
71
|
+
border-radius: var(--radius);
|
|
72
|
+
color: var(--text-primary);
|
|
73
|
+
padding: 6px 12px;
|
|
74
|
+
font-size: 13px;
|
|
75
|
+
width: 200px;
|
|
76
|
+
outline: none;
|
|
77
|
+
transition: border-color 150ms;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.search-input:focus { border-color: var(--accent); }
|
|
81
|
+
|
|
82
|
+
.btn-toggle {
|
|
83
|
+
background: var(--bg-elevated);
|
|
84
|
+
border: 1px solid var(--border);
|
|
85
|
+
color: var(--text-secondary);
|
|
86
|
+
padding: 6px 12px;
|
|
87
|
+
border-radius: var(--radius);
|
|
88
|
+
cursor: pointer;
|
|
89
|
+
font-size: 13px;
|
|
90
|
+
transition: all 150ms;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.btn-toggle:hover { border-color: var(--accent); color: var(--text-primary); }
|
|
94
|
+
.btn-toggle.active { background: var(--accent); color: var(--bg-primary); border-color: var(--accent); }
|
|
95
|
+
|
|
96
|
+
.main {
|
|
97
|
+
display: flex;
|
|
98
|
+
height: calc(100vh - var(--header-h));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.sidebar {
|
|
102
|
+
width: var(--sidebar-w);
|
|
103
|
+
background: var(--bg-surface);
|
|
104
|
+
border-right: 1px solid var(--border);
|
|
105
|
+
display: flex;
|
|
106
|
+
flex-direction: column;
|
|
107
|
+
transition: width 200ms ease, opacity 200ms ease;
|
|
108
|
+
overflow: hidden;
|
|
109
|
+
flex-shrink: 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.sidebar.collapsed { width: 0; opacity: 0; pointer-events: none; }
|
|
113
|
+
|
|
114
|
+
.sidebar-content {
|
|
115
|
+
flex: 1;
|
|
116
|
+
padding: 16px;
|
|
117
|
+
overflow-y: auto;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.sidebar-empty {
|
|
121
|
+
color: var(--text-secondary);
|
|
122
|
+
font-size: 13px;
|
|
123
|
+
text-align: center;
|
|
124
|
+
padding-top: 32px;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.sidebar-filters {
|
|
128
|
+
border-top: 1px solid var(--border);
|
|
129
|
+
padding: 12px 16px;
|
|
130
|
+
max-height: 240px;
|
|
131
|
+
overflow-y: auto;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.sidebar-filters h3 {
|
|
135
|
+
font-size: 11px;
|
|
136
|
+
text-transform: uppercase;
|
|
137
|
+
letter-spacing: 0.5px;
|
|
138
|
+
color: var(--text-secondary);
|
|
139
|
+
margin-bottom: 8px;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.filter-row {
|
|
143
|
+
display: flex;
|
|
144
|
+
align-items: center;
|
|
145
|
+
gap: 6px;
|
|
146
|
+
padding: 2px 0;
|
|
147
|
+
font-size: 12px;
|
|
148
|
+
cursor: pointer;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.filter-dot {
|
|
152
|
+
width: 10px;
|
|
153
|
+
height: 10px;
|
|
154
|
+
border-radius: 50%;
|
|
155
|
+
flex-shrink: 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.node-detail-label {
|
|
159
|
+
font-size: 11px;
|
|
160
|
+
text-transform: uppercase;
|
|
161
|
+
letter-spacing: 0.5px;
|
|
162
|
+
color: var(--text-secondary);
|
|
163
|
+
margin-bottom: 4px;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.node-detail-value {
|
|
167
|
+
font-size: 14px;
|
|
168
|
+
margin-bottom: 12px;
|
|
169
|
+
word-break: break-all;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.node-detail-value code {
|
|
173
|
+
font-family: var(--font-mono);
|
|
174
|
+
font-size: 12px;
|
|
175
|
+
background: var(--bg-elevated);
|
|
176
|
+
padding: 2px 6px;
|
|
177
|
+
border-radius: var(--radius);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.badge {
|
|
181
|
+
display: inline-block;
|
|
182
|
+
padding: 2px 8px;
|
|
183
|
+
border-radius: var(--radius);
|
|
184
|
+
font-size: 11px;
|
|
185
|
+
font-weight: 600;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.badge-context {
|
|
189
|
+
background: var(--accent);
|
|
190
|
+
color: var(--bg-primary);
|
|
191
|
+
animation: pulse 2s ease-in-out infinite;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.badge-community {
|
|
195
|
+
color: var(--text-primary);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.relations-list {
|
|
199
|
+
list-style: none;
|
|
200
|
+
margin-top: 8px;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.relations-list li {
|
|
204
|
+
padding: 4px 0;
|
|
205
|
+
font-size: 12px;
|
|
206
|
+
cursor: pointer;
|
|
207
|
+
color: var(--accent-dim);
|
|
208
|
+
transition: color 100ms;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.relations-list li:hover { color: var(--accent); }
|
|
212
|
+
|
|
213
|
+
.relations-list .rel-type {
|
|
214
|
+
color: var(--text-secondary);
|
|
215
|
+
margin-right: 4px;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.canvas {
|
|
219
|
+
flex: 1;
|
|
220
|
+
position: relative;
|
|
221
|
+
overflow: hidden;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#graph-svg {
|
|
225
|
+
width: 100%;
|
|
226
|
+
height: 100%;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.tooltip {
|
|
230
|
+
position: absolute;
|
|
231
|
+
background: var(--bg-surface);
|
|
232
|
+
border: 1px solid var(--border);
|
|
233
|
+
border-radius: var(--radius);
|
|
234
|
+
padding: 8px 12px;
|
|
235
|
+
font-size: 12px;
|
|
236
|
+
pointer-events: none;
|
|
237
|
+
opacity: 0;
|
|
238
|
+
transition: opacity 100ms;
|
|
239
|
+
z-index: 20;
|
|
240
|
+
max-width: 300px;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.tooltip.visible { opacity: 1; }
|
|
244
|
+
|
|
245
|
+
@keyframes pulse {
|
|
246
|
+
0%, 100% { opacity: 1; }
|
|
247
|
+
50% { opacity: 0.7; }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
@media (prefers-reduced-motion: reduce) {
|
|
251
|
+
.badge-context { animation: none; }
|
|
252
|
+
.sidebar { transition: none; }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
@media (max-width: 768px) {
|
|
256
|
+
.sidebar { width: 0; opacity: 0; pointer-events: none; }
|
|
257
|
+
.search-input { width: 120px; }
|
|
258
|
+
.stats { display: none; }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
*:focus-visible {
|
|
262
|
+
outline: 2px solid var(--accent);
|
|
263
|
+
outline-offset: 2px;
|
|
264
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { readFileSync, existsSync, writeFileSync } from "fs";
|
|
3
|
+
import { resolve, dirname } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
|
|
8
|
+
// Project root = where the user ran the command (their project directory)
|
|
9
|
+
const projectRoot = process.env.PMC_PROJECT_ROOT
|
|
10
|
+
? resolve(process.env.PMC_PROJECT_ROOT)
|
|
11
|
+
: resolve(process.cwd());
|
|
12
|
+
|
|
13
|
+
const GRAPH_PATH = resolve(projectRoot, ".planning/project-memory-context/graph/graph.json");
|
|
14
|
+
const WORKLIST_PATH = resolve(projectRoot, ".planning/project-memory-context/enrichment/worklist.json");
|
|
15
|
+
const TRACKER_PATH = resolve(projectRoot, ".planning/project-memory-context/context-tracker.json");
|
|
16
|
+
|
|
17
|
+
if (!existsSync(resolve(projectRoot, ".planning/project-memory-context"))) {
|
|
18
|
+
console.error(`Error: No PMC data found at ${projectRoot}`);
|
|
19
|
+
console.error(`Run this command from a project with .planning/project-memory-context/`);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const app = express();
|
|
24
|
+
const PORT = 3001;
|
|
25
|
+
|
|
26
|
+
app.use(express.static(resolve(__dirname, "public")));
|
|
27
|
+
app.use(express.json());
|
|
28
|
+
|
|
29
|
+
app.get("/api/graph", (req, res) => {
|
|
30
|
+
try {
|
|
31
|
+
const data = JSON.parse(readFileSync(GRAPH_PATH, "utf-8"));
|
|
32
|
+
res.json(data);
|
|
33
|
+
} catch {
|
|
34
|
+
res.status(404).json({ error: "graph.json not found" });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
app.get("/api/worklist", (req, res) => {
|
|
39
|
+
try {
|
|
40
|
+
const data = JSON.parse(readFileSync(WORKLIST_PATH, "utf-8"));
|
|
41
|
+
res.json(data);
|
|
42
|
+
} catch {
|
|
43
|
+
res.status(404).json({ error: "worklist.json not found" });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
app.get("/api/context", (req, res) => {
|
|
48
|
+
if (!existsSync(TRACKER_PATH)) {
|
|
49
|
+
return res.json({ activeNodeIds: [] });
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const data = JSON.parse(readFileSync(TRACKER_PATH, "utf-8"));
|
|
53
|
+
res.json(data);
|
|
54
|
+
} catch {
|
|
55
|
+
res.json({ activeNodeIds: [] });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
app.post("/api/context", (req, res) => {
|
|
60
|
+
const { add } = req.body;
|
|
61
|
+
if (!Array.isArray(add)) return res.status(400).json({ error: "add must be array" });
|
|
62
|
+
let tracker = { activeNodeIds: [] };
|
|
63
|
+
if (existsSync(TRACKER_PATH)) {
|
|
64
|
+
try { tracker = JSON.parse(readFileSync(TRACKER_PATH, "utf-8")); } catch {}
|
|
65
|
+
}
|
|
66
|
+
const existing = new Set(tracker.activeNodeIds || []);
|
|
67
|
+
add.forEach((id) => existing.add(id));
|
|
68
|
+
tracker.activeNodeIds = [...existing];
|
|
69
|
+
writeFileSync(TRACKER_PATH, JSON.stringify(tracker, null, 2));
|
|
70
|
+
res.json(tracker);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
app.listen(PORT, () => {
|
|
74
|
+
console.log(`PMC Graph Explorer running at http://localhost:${PORT}`);
|
|
75
|
+
console.log(`Project root: ${projectRoot}`);
|
|
76
|
+
});
|