@datasynx/agentic-ai-cartography 0.2.5 → 0.3.1

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/dist/index.js CHANGED
@@ -9,7 +9,7 @@ PRAGMA busy_timeout = 5000;
9
9
 
10
10
  CREATE TABLE IF NOT EXISTS sessions (
11
11
  id TEXT PRIMARY KEY,
12
- mode TEXT NOT NULL CHECK (mode IN ('discover','shadow')),
12
+ mode TEXT NOT NULL CHECK (mode IN ('discover')),
13
13
  started_at TEXT NOT NULL,
14
14
  completed_at TEXT,
15
15
  config TEXT NOT NULL DEFAULT '{}'
@@ -454,14 +454,6 @@ var EDGE_RELATIONSHIPS = [
454
454
  "contains",
455
455
  "depends_on"
456
456
  ];
457
- var EVENT_TYPES = [
458
- "process_start",
459
- "process_end",
460
- "connection_open",
461
- "connection_close",
462
- "window_focus",
463
- "tool_switch"
464
- ];
465
457
  var NodeSchema = z.object({
466
458
  id: z.string().describe('Format: "{type}:{host}:{port}" oder "{type}:{name}"'),
467
459
  type: z.enum(NODE_TYPES),
@@ -478,15 +470,6 @@ var EdgeSchema = z.object({
478
470
  evidence: z.string(),
479
471
  confidence: z.number().min(0).max(1).default(0.5)
480
472
  });
481
- var EventSchema = z.object({
482
- eventType: z.enum(EVENT_TYPES),
483
- process: z.string(),
484
- pid: z.number(),
485
- target: z.string().optional(),
486
- targetType: z.enum(NODE_TYPES).optional(),
487
- protocol: z.string().optional(),
488
- port: z.number().optional()
489
- });
490
473
  var SOPStepSchema = z.object({
491
474
  order: z.number(),
492
475
  instruction: z.string(),
@@ -503,27 +486,15 @@ var SOPSchema = z.object({
503
486
  frequency: z.string(),
504
487
  confidence: z.number().min(0).max(1)
505
488
  });
506
- var MIN_POLL_INTERVAL_MS = 15e3;
507
489
  function defaultConfig(overrides = {}) {
508
490
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
509
491
  return {
510
- mode: "discover",
511
492
  maxDepth: 8,
512
493
  maxTurns: 50,
513
494
  entryPoints: ["localhost"],
514
495
  agentModel: "claude-sonnet-4-5-20250929",
515
- shadowMode: "daemon",
516
- pollIntervalMs: 3e4,
517
- inactivityTimeoutMs: 3e5,
518
- promptTimeoutMs: 6e4,
519
- trackWindowFocus: false,
520
- autoSaveNodes: false,
521
- enableNotifications: true,
522
- shadowModel: "claude-haiku-4-5-20251001",
523
496
  outputDir: "./cartography-output",
524
497
  dbPath: `${home}/.cartography/cartography.db`,
525
- socketPath: `${home}/.cartography/daemon.sock`,
526
- pidFile: `${home}/.cartography/daemon.pid`,
527
498
  verbose: false,
528
499
  ...overrides
529
500
  };
@@ -693,24 +664,36 @@ function chromeLikeHistoryPaths(base) {
693
664
  }
694
665
  var CHROME_BASE = IS_MAC ? `${HOME}/Library/Application Support/Google/Chrome` : `${HOME}/.config/google-chrome`;
695
666
  var CHROMIUM_BASE = IS_MAC ? `${HOME}/Library/Application Support/Chromium` : `${HOME}/.config/chromium`;
667
+ var CHROMIUM_SNAP_BASE = `${HOME}/snap/chromium/common/chromium`;
668
+ var CHROMIUM_FLATPAK_BASE = `${HOME}/.var/app/org.chromium.Chromium/config/chromium`;
669
+ var CHROME_FLATPAK_BASE = `${HOME}/.var/app/com.google.Chrome/config/google-chrome`;
670
+ var BRAVE_FLATPAK_BASE = `${HOME}/.var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser`;
671
+ var EDGE_FLATPAK_BASE = `${HOME}/.var/app/com.microsoft.Edge/config/microsoft-edge`;
672
+ var FIREFOX_SNAP_BASE = `${HOME}/snap/firefox/common/.mozilla/firefox`;
673
+ var FIREFOX_FLATPAK_BASE = `${HOME}/.var/app/org.mozilla.firefox/.mozilla/firefox`;
696
674
  var EDGE_BASE = IS_MAC ? `${HOME}/Library/Application Support/Microsoft Edge` : `${HOME}/.config/microsoft-edge`;
697
675
  var BRAVE_BASE = IS_MAC ? `${HOME}/Library/Application Support/BraveSoftware/Brave-Browser` : `${HOME}/.config/BraveSoftware/Brave-Browser`;
698
676
  var VIVALDI_BASE = IS_MAC ? `${HOME}/Library/Application Support/Vivaldi` : `${HOME}/.config/vivaldi`;
699
677
  var OPERA_BASE = IS_MAC ? `${HOME}/Library/Application Support/com.operasoftware.Opera` : `${HOME}/.config/opera`;
700
678
  function firefoxProfileDirs() {
701
- const base = IS_MAC ? `${HOME}/Library/Application Support/Firefox/Profiles` : `${HOME}/.mozilla/firefox`;
702
- if (!existsSync(base)) return [];
703
- try {
704
- return readdirSync(base).map((d) => join(base, d)).filter((d) => {
705
- try {
706
- return statSync(d).isDirectory() && existsSync(join(d, "places.sqlite"));
707
- } catch {
708
- return false;
679
+ const bases = IS_MAC ? [`${HOME}/Library/Application Support/Firefox/Profiles`] : [`${HOME}/.mozilla/firefox`, FIREFOX_SNAP_BASE, FIREFOX_FLATPAK_BASE];
680
+ const dirs = [];
681
+ for (const base of bases) {
682
+ if (!existsSync(base)) continue;
683
+ try {
684
+ for (const d of readdirSync(base)) {
685
+ const full = join(base, d);
686
+ try {
687
+ if (statSync(full).isDirectory() && existsSync(join(full, "places.sqlite"))) {
688
+ dirs.push(full);
689
+ }
690
+ } catch {
691
+ }
709
692
  }
710
- });
711
- } catch {
712
- return [];
693
+ } catch {
694
+ }
713
695
  }
696
+ return dirs;
714
697
  }
715
698
  async function scanAllBookmarks() {
716
699
  const all = [];
@@ -720,6 +703,13 @@ async function scanAllBookmarks() {
720
703
  for (const p of chromeLikePaths(BRAVE_BASE)) all.push(...readChromeLike(p, "brave"));
721
704
  for (const p of chromeLikePaths(VIVALDI_BASE)) all.push(...readChromeLike(p, "vivaldi"));
722
705
  for (const p of chromeLikePaths(OPERA_BASE)) all.push(...readChromeLike(p, "opera"));
706
+ if (!IS_MAC) {
707
+ for (const p of chromeLikePaths(CHROMIUM_SNAP_BASE)) all.push(...readChromeLike(p, "chromium-snap"));
708
+ for (const p of chromeLikePaths(CHROMIUM_FLATPAK_BASE)) all.push(...readChromeLike(p, "chromium-flatpak"));
709
+ for (const p of chromeLikePaths(CHROME_FLATPAK_BASE)) all.push(...readChromeLike(p, "chrome-flatpak"));
710
+ for (const p of chromeLikePaths(BRAVE_FLATPAK_BASE)) all.push(...readChromeLike(p, "brave-flatpak"));
711
+ for (const p of chromeLikePaths(EDGE_FLATPAK_BASE)) all.push(...readChromeLike(p, "edge-flatpak"));
712
+ }
723
713
  for (const dir of firefoxProfileDirs()) {
724
714
  all.push(...await readFirefoxBookmarks(dir));
725
715
  }
@@ -738,6 +728,13 @@ async function scanAllHistory() {
738
728
  for (const p of chromeLikeHistoryPaths(BRAVE_BASE)) all.push(...await readChromiumHistory(p, "brave"));
739
729
  for (const p of chromeLikeHistoryPaths(VIVALDI_BASE)) all.push(...await readChromiumHistory(p, "vivaldi"));
740
730
  for (const p of chromeLikeHistoryPaths(OPERA_BASE)) all.push(...await readChromiumHistory(p, "opera"));
731
+ if (!IS_MAC) {
732
+ for (const p of chromeLikeHistoryPaths(CHROMIUM_SNAP_BASE)) all.push(...await readChromiumHistory(p, "chromium-snap"));
733
+ for (const p of chromeLikeHistoryPaths(CHROMIUM_FLATPAK_BASE)) all.push(...await readChromiumHistory(p, "chromium-flatpak"));
734
+ for (const p of chromeLikeHistoryPaths(CHROME_FLATPAK_BASE)) all.push(...await readChromiumHistory(p, "chrome-flatpak"));
735
+ for (const p of chromeLikeHistoryPaths(BRAVE_FLATPAK_BASE)) all.push(...await readChromiumHistory(p, "brave-flatpak"));
736
+ for (const p of chromeLikeHistoryPaths(EDGE_FLATPAK_BASE)) all.push(...await readChromiumHistory(p, "edge-flatpak"));
737
+ }
741
738
  for (const dir of firefoxProfileDirs()) {
742
739
  all.push(...await readFirefoxHistory(dir));
743
740
  }
@@ -803,24 +800,6 @@ async function createCartographyTools(db, sessionId, opts = {}) {
803
800
  });
804
801
  return { content: [{ type: "text", text: `\u2713 ${args["sourceId"]}\u2192${args["targetId"]}` }] };
805
802
  }),
806
- tool("save_event", "Save an activity event (process/connection observed)", {
807
- eventType: z2.enum(EVENT_TYPES),
808
- process: z2.string(),
809
- pid: z2.number(),
810
- target: z2.string().optional(),
811
- targetType: z2.enum(NODE_TYPES).optional(),
812
- port: z2.number().optional()
813
- }, async (args) => {
814
- db.insertEvent(sessionId, {
815
- eventType: args["eventType"],
816
- process: args["process"],
817
- pid: args["pid"],
818
- target: args["target"] ? stripSensitive(args["target"]) : void 0,
819
- targetType: args["targetType"],
820
- port: args["port"]
821
- });
822
- return { content: [{ type: "text", text: `\u2713 ${args["eventType"]}` }] };
823
- }),
824
803
  tool("get_catalog", "Get the current catalog \u2014 use before save_node to avoid duplicates", {
825
804
  includeEdges: z2.boolean().default(true)
826
805
  }, async (args) => {
@@ -836,22 +815,6 @@ async function createCartographyTools(db, sessionId, opts = {}) {
836
815
  }]
837
816
  };
838
817
  }),
839
- tool("manage_task", "Start, end or describe a workflow task", {
840
- action: z2.enum(["start", "end", "describe"]),
841
- description: z2.string().optional()
842
- }, async (args) => {
843
- const action = args["action"];
844
- if (action === "start") {
845
- const id = db.startTask(sessionId, args["description"]);
846
- return { content: [{ type: "text", text: `\u2713 Task started: ${id}` }] };
847
- }
848
- if (action === "end") {
849
- db.endCurrentTask(sessionId);
850
- return { content: [{ type: "text", text: "\u2713 Task ended" }] };
851
- }
852
- db.updateTaskDescription(sessionId, args["description"]);
853
- return { content: [{ type: "text", text: "\u2713 Description updated" }] };
854
- }),
855
818
  tool("ask_user", "Ask the user a question \u2014 for clarifications, missing context, or consent (e.g. before scanning browser history)", {
856
819
  question: z2.string().describe("The question for the user (clear and specific)"),
857
820
  context: z2.string().optional().describe("Optional context explaining why this is relevant")
@@ -1444,109 +1407,6 @@ Use ask_user when you need context from the user.`;
1444
1407
  }
1445
1408
  }
1446
1409
  }
1447
- async function runShadowCycle(config, db, sessionId, prevSnapshot, currSnapshot, onOutput) {
1448
- const { query } = await import("@anthropic-ai/claude-code");
1449
- const tools = await createCartographyTools(db, sessionId);
1450
- const prompt = `Analyze the diff between these two system snapshots.
1451
- Find:
1452
- - New/closed TCP connections \u2192 save_event
1453
- - New/terminated processes \u2192 save_event
1454
- - Previously unknown services \u2192 check get_catalog, then save_node
1455
- - Task boundaries (inactivity, tool switches) \u2192 manage_task
1456
- target = host:port ONLY. Be concise and efficient.
1457
-
1458
- === BEFORE ===
1459
- ${prevSnapshot}
1460
-
1461
- === NOW ===
1462
- ${currSnapshot}`;
1463
- for await (const msg of query({
1464
- prompt,
1465
- options: {
1466
- model: config.shadowModel,
1467
- maxTurns: 5,
1468
- mcpServers: { cartography: tools },
1469
- allowedTools: [
1470
- "mcp__cartograph__save_event",
1471
- "mcp__cartograph__save_node",
1472
- "mcp__cartograph__save_edge",
1473
- "mcp__cartograph__get_catalog",
1474
- "mcp__cartograph__manage_task"
1475
- ],
1476
- permissionMode: "bypassPermissions"
1477
- }
1478
- })) {
1479
- if (onOutput) onOutput(msg);
1480
- }
1481
- }
1482
- async function generateSOPs(db, sessionId) {
1483
- const Anthropic = (await import("@anthropic-ai/sdk")).default;
1484
- const client = new Anthropic();
1485
- const tasks = db.getTasks(sessionId).filter((t) => t.status === "completed");
1486
- if (tasks.length === 0) return 0;
1487
- const clusters = clusterTasks(tasks);
1488
- let generated = 0;
1489
- for (const cluster of clusters) {
1490
- const workflowId = crypto.randomUUID();
1491
- const involved = JSON.parse(cluster[0]?.involvedServices ?? "[]");
1492
- const taskDescriptions = cluster.map((t, i) => `Task ${i + 1}: ${t.description ?? "Unnamed"}
1493
- Steps: ${t.steps}`).join("\n\n");
1494
- const response = await client.messages.create({
1495
- model: "claude-sonnet-4-5-20250929",
1496
- max_tokens: 2048,
1497
- messages: [{
1498
- role: "user",
1499
- content: `Generate a Standard Operating Procedure (SOP) for this recurring workflow.
1500
- Reply ONLY with valid JSON in this format:
1501
- {
1502
- "title": "...",
1503
- "description": "...",
1504
- "steps": [{"order": 1, "instruction": "...", "tool": "...", "target": "...", "notes": "..."}],
1505
- "involvedSystems": ["..."],
1506
- "estimatedDuration": "~N minutes",
1507
- "frequency": "X times daily",
1508
- "confidence": 0.8
1509
- }
1510
-
1511
- Tasks:
1512
- ${taskDescriptions}
1513
-
1514
- Involved services: ${involved.join(", ")}`
1515
- }]
1516
- });
1517
- const text = response.content[0]?.type === "text" ? response.content[0].text : "";
1518
- try {
1519
- const jsonMatch = text.match(/\{[\s\S]*\}/);
1520
- if (!jsonMatch) continue;
1521
- const parsed = JSON.parse(jsonMatch[0]);
1522
- db.insertSOP({ workflowId, ...parsed });
1523
- generated++;
1524
- } catch {
1525
- }
1526
- }
1527
- return generated;
1528
- }
1529
- function clusterTasks(tasks) {
1530
- const clusters = [];
1531
- const assigned = /* @__PURE__ */ new Set();
1532
- for (const task of tasks) {
1533
- if (assigned.has(task.id)) continue;
1534
- const cluster = [task];
1535
- assigned.add(task.id);
1536
- const taskServices = new Set(JSON.parse(task.involvedServices ?? "[]"));
1537
- for (const other of tasks) {
1538
- if (assigned.has(other.id)) continue;
1539
- const otherServices = new Set(JSON.parse(other.involvedServices ?? "[]"));
1540
- const overlap = [...taskServices].filter((s) => otherServices.has(s));
1541
- if (overlap.length > 0) {
1542
- cluster.push(other);
1543
- assigned.add(other.id);
1544
- }
1545
- }
1546
- clusters.push(cluster);
1547
- }
1548
- return clusters;
1549
- }
1550
1410
 
1551
1411
  // src/exporter.ts
1552
1412
  import { mkdirSync as mkdirSync2, writeFileSync } from "fs";
@@ -1783,7 +1643,36 @@ function exportHTML(nodes, edges) {
1783
1643
  <script src="https://d3js.org/d3.v7.min.js"></script>
1784
1644
  <style>
1785
1645
  * { box-sizing: border-box; margin: 0; padding: 0; }
1786
- body { background: #0a0e14; color: #e6edf3; font-family: 'SF Mono','Fira Code','Cascadia Code',monospace; display: flex; overflow: hidden; }
1646
+ body { background: #0a0e14; color: #e6edf3; font-family: 'SF Mono','Fira Code','Cascadia Code',monospace; display: flex; overflow: hidden; height: 100vh; }
1647
+
1648
+ /* \u2500\u2500 Left node panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1649
+ #node-panel {
1650
+ width: 220px; min-width: 220px; height: 100vh; overflow: hidden;
1651
+ background: #0d1117; border-right: 1px solid #1b2028;
1652
+ display: flex; flex-direction: column;
1653
+ }
1654
+ #node-panel-header {
1655
+ padding: 10px 12px 8px; border-bottom: 1px solid #1b2028;
1656
+ font-size: 11px; color: #6e7681; text-transform: uppercase; letter-spacing: 0.6px;
1657
+ }
1658
+ #node-search {
1659
+ width: calc(100% - 16px); margin: 8px; padding: 5px 8px;
1660
+ background: #161b22; border: 1px solid #30363d; border-radius: 5px;
1661
+ color: #e6edf3; font-size: 11px; font-family: inherit; outline: none;
1662
+ }
1663
+ #node-search:focus { border-color: #58a6ff; }
1664
+ #node-list { flex: 1; overflow-y: auto; padding-bottom: 8px; }
1665
+ .node-list-item {
1666
+ padding: 5px 12px; cursor: pointer; font-size: 11px;
1667
+ display: flex; align-items: center; gap: 6px; border-left: 2px solid transparent;
1668
+ }
1669
+ .node-list-item:hover { background: #161b22; }
1670
+ .node-list-item.active { background: #1a2436; border-left-color: #58a6ff; }
1671
+ .node-list-dot { width: 7px; height: 7px; border-radius: 2px; flex-shrink: 0; }
1672
+ .node-list-name { color: #c9d1d9; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
1673
+ .node-list-type { color: #484f58; font-size: 9px; flex-shrink: 0; }
1674
+
1675
+ /* \u2500\u2500 Center graph \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1787
1676
  #graph { flex: 1; height: 100vh; position: relative; }
1788
1677
  svg { width: 100%; height: 100%; }
1789
1678
  .hull { opacity: 0.12; stroke-width: 1.5; stroke-opacity: 0.25; }
@@ -1792,10 +1681,12 @@ function exportHTML(nodes, edges) {
1792
1681
  .link-label { font-size: 8px; fill: #6e7681; pointer-events: none; opacity: 0; }
1793
1682
  .node-hex { stroke-width: 1.8; cursor: pointer; transition: opacity 0.15s; }
1794
1683
  .node-hex:hover { filter: brightness(1.3); stroke-width: 3; }
1684
+ .node-hex.selected { stroke-width: 3.5; filter: brightness(1.5); }
1795
1685
  .node-label { font-size: 10px; fill: #c9d1d9; pointer-events: none; opacity: 0; }
1796
- /* Sidebar */
1686
+
1687
+ /* \u2500\u2500 Right sidebar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1797
1688
  #sidebar {
1798
- width: 320px; min-width: 320px; height: 100vh; overflow-y: auto;
1689
+ width: 300px; min-width: 300px; height: 100vh; overflow-y: auto;
1799
1690
  background: #0d1117; border-left: 1px solid #1b2028;
1800
1691
  padding: 16px; font-size: 12px; line-height: 1.6;
1801
1692
  }
@@ -1809,15 +1700,24 @@ function exportHTML(nodes, edges) {
1809
1700
  #sidebar .edges-list { margin-top: 12px; }
1810
1701
  #sidebar .edge-item { padding: 4px 0; border-bottom: 1px solid #161b22; color: #6e7681; font-size: 11px; }
1811
1702
  #sidebar .edge-item span { color: #c9d1d9; }
1703
+ #sidebar .action-row { display: flex; gap: 6px; margin-top: 14px; }
1704
+ .btn-delete {
1705
+ flex: 1; padding: 6px 10px; background: transparent; border: 1px solid #6e191d;
1706
+ color: #f85149; border-radius: 5px; font-size: 11px; font-family: inherit;
1707
+ cursor: pointer; text-align: center;
1708
+ }
1709
+ .btn-delete:hover { background: #3d0c0c; }
1812
1710
  .hint { color: #3d434b; font-size: 11px; margin-top: 8px; }
1813
- /* HUD */
1711
+
1712
+ /* \u2500\u2500 HUD \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1814
1713
  #hud { position: absolute; top: 10px; left: 10px; background: rgba(10,14,20,0.88);
1815
1714
  padding: 10px 14px; border-radius: 8px; font-size: 12px; border: 1px solid #1b2028; pointer-events: none; }
1816
1715
  #hud strong { color: #58a6ff; }
1817
1716
  #hud .stats { color: #6e7681; }
1818
1717
  #hud .zoom-level { color: #3d434b; font-size: 10px; margin-top: 2px; }
1819
- /* Layer filter */
1820
- #filters { position: absolute; top: 10px; right: 330px; display: flex; flex-wrap: wrap; gap: 4px; pointer-events: auto; }
1718
+
1719
+ /* \u2500\u2500 Toolbar (filters + JGF export) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1720
+ #toolbar { position: absolute; top: 10px; right: 10px; display: flex; flex-wrap: wrap; gap: 4px; pointer-events: auto; align-items: center; }
1821
1721
  .filter-btn {
1822
1722
  background: rgba(10,14,20,0.85); border: 1px solid #1b2028; border-radius: 6px;
1823
1723
  color: #c9d1d9; padding: 4px 10px; font-size: 11px; cursor: pointer;
@@ -1826,22 +1726,40 @@ function exportHTML(nodes, edges) {
1826
1726
  .filter-btn:hover { border-color: #30363d; }
1827
1727
  .filter-btn.off { opacity: 0.35; }
1828
1728
  .filter-dot { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
1729
+ .export-btn {
1730
+ background: rgba(10,14,20,0.85); border: 1px solid #1b2028; border-radius: 6px;
1731
+ color: #58a6ff; padding: 4px 12px; font-size: 11px; cursor: pointer;
1732
+ font-family: inherit;
1733
+ }
1734
+ .export-btn:hover { border-color: #58a6ff; background: rgba(88,166,255,0.08); }
1829
1735
  </style>
1830
1736
  </head>
1831
1737
  <body>
1738
+
1739
+ <!-- Left: node list panel -->
1740
+ <div id="node-panel">
1741
+ <div id="node-panel-header">Nodes (${nodes.length})</div>
1742
+ <input id="node-search" type="text" placeholder="Search nodes\u2026" autocomplete="off" spellcheck="false">
1743
+ <div id="node-list"></div>
1744
+ </div>
1745
+
1746
+ <!-- Center: graph -->
1832
1747
  <div id="graph">
1833
1748
  <div id="hud">
1834
1749
  <strong>Cartography</strong> &nbsp;
1835
- <span class="stats">${nodes.length} nodes \xB7 ${edges.length} edges</span><br>
1750
+ <span class="stats" id="hud-stats">${nodes.length} nodes \xB7 ${edges.length} edges</span><br>
1836
1751
  <span class="zoom-level">Scroll = zoom \xB7 Drag = pan \xB7 Click = details</span>
1837
1752
  </div>
1838
- <div id="filters"></div>
1753
+ <div id="toolbar"></div>
1839
1754
  <svg></svg>
1840
1755
  </div>
1756
+
1757
+ <!-- Right: detail sidebar -->
1841
1758
  <div id="sidebar">
1842
1759
  <h2>Infrastructure Map</h2>
1843
1760
  <p class="hint">Click a node to view details.</p>
1844
1761
  </div>
1762
+
1845
1763
  <script>
1846
1764
  const data = ${graphData};
1847
1765
 
@@ -1854,7 +1772,6 @@ const TYPE_COLORS = {
1854
1772
  config_file: '#adb5bd', saas_tool: '#c084fc', table: '#f97316', unknown: '#6c757d',
1855
1773
  };
1856
1774
 
1857
- // \u2500\u2500 Color per layer (for hull backgrounds) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1858
1775
  const LAYER_COLORS = {
1859
1776
  saas: '#c084fc', web: '#6bcb77', data: '#ff6b6b',
1860
1777
  messaging: '#c77dff', infra: '#4a9eff', config: '#adb5bd', other: '#6c757d',
@@ -1864,7 +1781,7 @@ const LAYER_NAMES = {
1864
1781
  messaging: 'Messaging', infra: 'Infrastructure', config: 'Config', other: 'Other',
1865
1782
  };
1866
1783
 
1867
- // \u2500\u2500 Hexagon path generator \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1784
+ // \u2500\u2500 Hexagon path \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1868
1785
  const HEX_SIZE = { saas_tool: 16, host: 18, database_server: 18, k8s_cluster: 20, default: 14 };
1869
1786
  function hexSize(d) { return HEX_SIZE[d.type] || HEX_SIZE.default; }
1870
1787
  function hexPath(size) {
@@ -1876,9 +1793,42 @@ function hexPath(size) {
1876
1793
  return 'M' + pts.map(p => p.join(',')).join('L') + 'Z';
1877
1794
  }
1878
1795
 
1879
- // \u2500\u2500 Sidebar detail view \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1796
+ // \u2500\u2500 Left panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1797
+ const nodeListEl = document.getElementById('node-list');
1798
+ const nodeSearchEl = document.getElementById('node-search');
1799
+ let selectedNodeId = null;
1800
+
1801
+ function buildNodeList(filter) {
1802
+ const q = (filter || '').toLowerCase();
1803
+ nodeListEl.innerHTML = '';
1804
+ const sorted = [...data.nodes].sort((a, b) => a.name.localeCompare(b.name));
1805
+ for (const d of sorted) {
1806
+ if (q && !d.name.toLowerCase().includes(q) && !d.type.includes(q) && !d.id.toLowerCase().includes(q)) continue;
1807
+ const item = document.createElement('div');
1808
+ item.className = 'node-list-item' + (d.id === selectedNodeId ? ' active' : '');
1809
+ item.dataset.id = d.id;
1810
+ const color = TYPE_COLORS[d.type] || '#aaa';
1811
+ item.innerHTML = \`<span class="node-list-dot" style="background:\${color}"></span>
1812
+ <span class="node-list-name" title="\${d.id}">\${d.name}</span>
1813
+ <span class="node-list-type">\${d.type.replace(/_/g,' ')}</span>\`;
1814
+ item.onclick = () => { selectNode(d); focusNode(d); };
1815
+ nodeListEl.appendChild(item);
1816
+ }
1817
+ }
1818
+
1819
+ nodeSearchEl.addEventListener('input', e => buildNodeList(e.target.value));
1820
+
1821
+ // \u2500\u2500 Sidebar detail view \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1880
1822
  const sidebar = document.getElementById('sidebar');
1881
1823
 
1824
+ function selectNode(d) {
1825
+ selectedNodeId = d.id;
1826
+ buildNodeList(nodeSearchEl.value);
1827
+ showNode(d);
1828
+ // highlight hex
1829
+ d3.selectAll('.node-hex').classed('selected', nd => nd.id === d.id);
1830
+ }
1831
+
1882
1832
  function showNode(d) {
1883
1833
  const c = TYPE_COLORS[d.type] || '#aaa';
1884
1834
  const confPct = Math.round(d.confidence * 100);
@@ -1912,9 +1862,28 @@ function showNode(d) {
1912
1862
  \${metaRows}
1913
1863
  </table>
1914
1864
  \${related.length > 0 ? '<div class="edges-list"><strong>Connections (' + related.length + '):</strong>'+edgeItems+'</div>' : ''}
1865
+ <div class="action-row">
1866
+ <button class="btn-delete" onclick="deleteNode('\${d.id}')">\u{1F5D1} Delete node</button>
1867
+ </div>
1915
1868
  \`;
1916
1869
  }
1917
1870
 
1871
+ // \u2500\u2500 Delete node \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1872
+ function deleteNode(id) {
1873
+ const idx = data.nodes.findIndex(n => n.id === id);
1874
+ if (idx === -1) return;
1875
+ data.nodes.splice(idx, 1);
1876
+ data.links = data.links.filter(l =>
1877
+ (l.source.id || l.source) !== id && (l.target.id || l.target) !== id
1878
+ );
1879
+ selectedNodeId = null;
1880
+ sidebar.innerHTML = '<h2>Infrastructure Map</h2><p class="hint">Node deleted.</p>';
1881
+ document.getElementById('hud-stats').textContent =
1882
+ data.nodes.length + ' nodes \xB7 ' + data.links.length + ' edges';
1883
+ rebuildGraph();
1884
+ buildNodeList(nodeSearchEl.value);
1885
+ }
1886
+
1918
1887
  // \u2500\u2500 SVG setup \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1919
1888
  const svgEl = d3.select('svg');
1920
1889
  const graphDiv = document.getElementById('graph');
@@ -1922,7 +1891,6 @@ const W = () => graphDiv.clientWidth;
1922
1891
  const H = () => graphDiv.clientHeight;
1923
1892
  const g = svgEl.append('g');
1924
1893
 
1925
- // Arrow marker for directed edges
1926
1894
  svgEl.append('defs').append('marker')
1927
1895
  .attr('id', 'arrow').attr('viewBox', '0 0 10 6')
1928
1896
  .attr('refX', 10).attr('refY', 3)
@@ -1931,7 +1899,6 @@ svgEl.append('defs').append('marker')
1931
1899
  .append('path').attr('d', 'M0,0 L10,3 L0,6 Z').attr('fill', '#555');
1932
1900
 
1933
1901
  let currentZoom = 1;
1934
-
1935
1902
  const zoomBehavior = d3.zoom().scaleExtent([0.08, 6]).on('zoom', e => {
1936
1903
  g.attr('transform', e.transform);
1937
1904
  currentZoom = e.transform.k;
@@ -1944,7 +1911,9 @@ const layers = [...new Set(data.nodes.map(d => d.layer))];
1944
1911
  const layerVisible = {};
1945
1912
  layers.forEach(l => layerVisible[l] = true);
1946
1913
 
1947
- const filtersDiv = document.getElementById('filters');
1914
+ const toolbarEl = document.getElementById('toolbar');
1915
+
1916
+ // Filter buttons
1948
1917
  layers.forEach(layer => {
1949
1918
  const btn = document.createElement('button');
1950
1919
  btn.className = 'filter-btn';
@@ -1954,10 +1923,47 @@ layers.forEach(layer => {
1954
1923
  btn.classList.toggle('off', !layerVisible[layer]);
1955
1924
  updateVisibility();
1956
1925
  };
1957
- filtersDiv.appendChild(btn);
1926
+ toolbarEl.appendChild(btn);
1958
1927
  });
1959
1928
 
1960
- // \u2500\u2500 Cluster force: attract same-layer nodes toward group centroid \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1929
+ // JGF export button
1930
+ const jgfBtn = document.createElement('button');
1931
+ jgfBtn.className = 'export-btn';
1932
+ jgfBtn.textContent = '\u2193 JGF';
1933
+ jgfBtn.title = 'Export JSON Graph Format';
1934
+ jgfBtn.onclick = exportJGF;
1935
+ toolbarEl.appendChild(jgfBtn);
1936
+
1937
+ // \u2500\u2500 JGF export \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1938
+ function exportJGF() {
1939
+ const jgf = {
1940
+ graph: {
1941
+ directed: true,
1942
+ type: 'cartography',
1943
+ label: 'Infrastructure Map',
1944
+ metadata: { exportedAt: new Date().toISOString() },
1945
+ nodes: Object.fromEntries(data.nodes.map(n => [n.id, {
1946
+ label: n.name,
1947
+ metadata: { type: n.type, layer: n.layer, confidence: n.confidence,
1948
+ discoveredVia: n.discoveredVia, discoveredAt: n.discoveredAt,
1949
+ tags: n.tags, ...n.metadata }
1950
+ }])),
1951
+ edges: data.links.map(l => ({
1952
+ source: l.source.id || l.source,
1953
+ target: l.target.id || l.target,
1954
+ relation: l.relationship,
1955
+ metadata: { confidence: l.confidence, evidence: l.evidence }
1956
+ })),
1957
+ }
1958
+ };
1959
+ const blob = new Blob([JSON.stringify(jgf, null, 2)], { type: 'application/json' });
1960
+ const url = URL.createObjectURL(blob);
1961
+ const a = document.createElement('a');
1962
+ a.href = url; a.download = 'cartography-graph.jgf.json'; a.click();
1963
+ URL.revokeObjectURL(url);
1964
+ }
1965
+
1966
+ // \u2500\u2500 Cluster force \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1961
1967
  function clusterForce(alpha) {
1962
1968
  const centroids = {};
1963
1969
  const counts = {};
@@ -1967,42 +1973,23 @@ function clusterForce(alpha) {
1967
1973
  centroids[d.layer].y += d.y || 0;
1968
1974
  counts[d.layer]++;
1969
1975
  });
1970
- for (const l in centroids) {
1971
- centroids[l].x /= counts[l];
1972
- centroids[l].y /= counts[l];
1973
- }
1976
+ for (const l in centroids) { centroids[l].x /= counts[l]; centroids[l].y /= counts[l]; }
1974
1977
  const strength = alpha * 0.15;
1975
1978
  data.nodes.forEach(d => {
1976
1979
  const c = centroids[d.layer];
1977
- if (c) {
1978
- d.vx += (c.x - d.x) * strength;
1979
- d.vy += (c.y - d.y) * strength;
1980
- }
1980
+ if (c) { d.vx += (c.x - d.x) * strength; d.vy += (c.y - d.y) * strength; }
1981
1981
  });
1982
1982
  }
1983
1983
 
1984
- // \u2500\u2500 Force simulation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1985
- const sim = d3.forceSimulation(data.nodes)
1986
- .force('link', d3.forceLink(data.links).id(d => d.id).distance(d => d.relationship === 'contains' ? 50 : 100).strength(0.4))
1987
- .force('charge', d3.forceManyBody().strength(-280))
1988
- .force('center', d3.forceCenter(W() / 2, H() / 2))
1989
- .force('collision', d3.forceCollide().radius(d => hexSize(d) + 10))
1990
- .force('cluster', clusterForce);
1991
-
1992
- // \u2500\u2500 Draw: hull backgrounds per layer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1984
+ // \u2500\u2500 Hull group \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1993
1985
  const hullGroup = g.append('g').attr('class', 'hulls');
1994
1986
  const hullPaths = {};
1995
1987
  const hullLabels = {};
1996
-
1997
1988
  layers.forEach(layer => {
1998
- hullPaths[layer] = hullGroup.append('path')
1999
- .attr('class', 'hull')
2000
- .attr('fill', LAYER_COLORS[layer] || '#666')
2001
- .attr('stroke', LAYER_COLORS[layer] || '#666');
2002
- hullLabels[layer] = hullGroup.append('text')
2003
- .attr('class', 'hull-label')
2004
- .attr('fill', LAYER_COLORS[layer] || '#666')
2005
- .text(LAYER_NAMES[layer] || layer);
1989
+ hullPaths[layer] = hullGroup.append('path').attr('class', 'hull')
1990
+ .attr('fill', LAYER_COLORS[layer] || '#666').attr('stroke', LAYER_COLORS[layer] || '#666');
1991
+ hullLabels[layer] = hullGroup.append('text').attr('class', 'hull-label')
1992
+ .attr('fill', LAYER_COLORS[layer] || '#666').text(LAYER_NAMES[layer] || layer);
2006
1993
  });
2007
1994
 
2008
1995
  function updateHulls() {
@@ -2017,7 +2004,6 @@ function updateHulls() {
2017
2004
  }
2018
2005
  const hull = d3.polygonHull(pts);
2019
2006
  if (!hull) { hullPaths[layer].attr('d', null); return; }
2020
- // Pad the hull outward for organic island feel
2021
2007
  const cx = d3.mean(hull, p => p[0]);
2022
2008
  const cy = d3.mean(hull, p => p[1]);
2023
2009
  const padded = hull.map(p => {
@@ -2030,94 +2016,119 @@ function updateHulls() {
2030
2016
  });
2031
2017
  }
2032
2018
 
2033
- // \u2500\u2500 Draw: edges \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2019
+ // \u2500\u2500 Graph rendering (rebuildable after delete) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2020
+ let linkSel, linkLabelSel, nodeSel, nodeLabelSel, sim;
2034
2021
  const linkGroup = g.append('g');
2035
- const link = linkGroup.selectAll('line').data(data.links).join('line')
2036
- .attr('class', 'link')
2037
- .attr('stroke', d => d.confidence < 0.6 ? '#2a2e35' : '#3d434b')
2038
- .attr('stroke-dasharray', d => d.confidence < 0.6 ? '4 3' : null)
2039
- .attr('stroke-width', d => d.confidence < 0.6 ? 0.8 : 1.2)
2040
- .attr('marker-end', 'url(#arrow)');
2041
-
2042
- link.append('title').text(d => \`\${d.relationship} (\${Math.round(d.confidence*100)}%)
2043
- \${d.evidence||''}\`);
2022
+ const nodeGroup = g.append('g');
2044
2023
 
2045
- // Edge labels
2046
- const linkLabel = linkGroup.selectAll('text').data(data.links).join('text')
2047
- .attr('class', 'link-label')
2048
- .text(d => d.relationship);
2024
+ function focusNode(d) {
2025
+ if (!d.x || !d.y) return;
2026
+ const w = W(), h = H();
2027
+ svgEl.transition().duration(500).call(
2028
+ zoomBehavior.transform,
2029
+ d3.zoomIdentity.translate(w / 2, h / 2).scale(Math.min(3, currentZoom < 1 ? 1.5 : currentZoom)).translate(-d.x, -d.y)
2030
+ );
2031
+ }
2049
2032
 
2050
- // \u2500\u2500 Draw: nodes (hexagons) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2051
- const nodeGroup = g.append('g');
2052
- const node = nodeGroup.selectAll('g').data(data.nodes).join('g')
2053
- .call(d3.drag()
2054
- .on('start', (e, d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
2055
- .on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
2056
- .on('end', (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; })
2057
- )
2058
- .on('click', (e, d) => { e.stopPropagation(); showNode(d); });
2059
-
2060
- node.append('path')
2061
- .attr('class', 'node-hex')
2062
- .attr('d', d => hexPath(hexSize(d)))
2063
- .attr('fill', d => TYPE_COLORS[d.type] || '#aaa')
2064
- .attr('stroke', d => {
2065
- const c = d3.color(TYPE_COLORS[d.type] || '#aaa');
2066
- return c ? c.brighter(0.8).formatHex() : '#ccc';
2067
- })
2068
- .attr('fill-opacity', d => 0.6 + d.confidence * 0.4);
2069
-
2070
- node.append('title').text(d => \`\${d.name} (\${d.type})
2071
- conf: \${Math.round(d.confidence*100)}%\`);
2033
+ function rebuildGraph() {
2034
+ if (sim) sim.stop();
2035
+
2036
+ // Links
2037
+ linkSel = linkGroup.selectAll('line').data(data.links, d => \`\${d.source.id||d.source}>\${d.target.id||d.target}\`);
2038
+ linkSel.exit().remove();
2039
+ const linkEnter = linkSel.enter().append('line').attr('class', 'link');
2040
+ linkSel = linkEnter.merge(linkSel)
2041
+ .attr('stroke', d => d.confidence < 0.6 ? '#2a2e35' : '#3d434b')
2042
+ .attr('stroke-dasharray', d => d.confidence < 0.6 ? '4 3' : null)
2043
+ .attr('stroke-width', d => d.confidence < 0.6 ? 0.8 : 1.2)
2044
+ .attr('marker-end', 'url(#arrow)');
2045
+ linkSel.select('title').remove();
2046
+ linkSel.append('title').text(d => \`\${d.relationship} (\${Math.round(d.confidence*100)}%)
2047
+ \${d.evidence||''}\`);
2072
2048
 
2073
- // Node labels
2074
- const nodeLabel = node.append('text')
2075
- .attr('class', 'node-label')
2076
- .attr('dy', d => hexSize(d) + 13)
2077
- .attr('text-anchor', 'middle')
2078
- .text(d => d.name.length > 20 ? d.name.substring(0, 18) + '\u2026' : d.name);
2049
+ // Link labels
2050
+ linkLabelSel = linkGroup.selectAll('text').data(data.links, d => \`\${d.source.id||d.source}>\${d.target.id||d.target}\`);
2051
+ linkLabelSel.exit().remove();
2052
+ linkLabelSel = linkLabelSel.enter().append('text').attr('class', 'link-label').merge(linkLabelSel)
2053
+ .text(d => d.relationship);
2054
+
2055
+ // Nodes
2056
+ nodeSel = nodeGroup.selectAll('g').data(data.nodes, d => d.id);
2057
+ nodeSel.exit().remove();
2058
+ const nodeEnter = nodeSel.enter().append('g')
2059
+ .call(d3.drag()
2060
+ .on('start', (e, d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
2061
+ .on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
2062
+ .on('end', (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; })
2063
+ )
2064
+ .on('click', (e, d) => { e.stopPropagation(); selectNode(d); });
2065
+ nodeEnter.append('path').attr('class', 'node-hex');
2066
+ nodeEnter.append('title');
2067
+ nodeEnter.append('text').attr('class', 'node-label').attr('text-anchor', 'middle');
2068
+
2069
+ nodeSel = nodeEnter.merge(nodeSel);
2070
+ nodeSel.select('.node-hex')
2071
+ .attr('d', d => hexPath(hexSize(d)))
2072
+ .attr('fill', d => TYPE_COLORS[d.type] || '#aaa')
2073
+ .attr('stroke', d => { const c = d3.color(TYPE_COLORS[d.type] || '#aaa'); return c ? c.brighter(0.8).formatHex() : '#ccc'; })
2074
+ .attr('fill-opacity', d => 0.6 + d.confidence * 0.4)
2075
+ .classed('selected', d => d.id === selectedNodeId);
2076
+ nodeSel.select('title').text(d => \`\${d.name} (\${d.type})
2077
+ conf: \${Math.round(d.confidence*100)}%\`);
2078
+ nodeLabelSel = nodeSel.select('.node-label')
2079
+ .attr('dy', d => hexSize(d) + 13)
2080
+ .text(d => d.name.length > 20 ? d.name.substring(0, 18) + '\u2026' : d.name);
2081
+
2082
+ // Simulation
2083
+ sim = d3.forceSimulation(data.nodes)
2084
+ .force('link', d3.forceLink(data.links).id(d => d.id).distance(d => d.relationship === 'contains' ? 50 : 100).strength(0.4))
2085
+ .force('charge', d3.forceManyBody().strength(-280))
2086
+ .force('center', d3.forceCenter(W() / 2, H() / 2))
2087
+ .force('collision', d3.forceCollide().radius(d => hexSize(d) + 10))
2088
+ .force('cluster', clusterForce)
2089
+ .on('tick', () => {
2090
+ updateHulls();
2091
+ linkSel.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
2092
+ .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
2093
+ linkLabelSel.attr('x', d => (d.source.x + d.target.x) / 2)
2094
+ .attr('y', d => (d.source.y + d.target.y) / 2 - 4);
2095
+ nodeSel.attr('transform', d => \`translate(\${d.x},\${d.y})\`);
2096
+ });
2097
+ }
2079
2098
 
2080
- // \u2500\u2500 Level-of-detail: show/hide based on zoom \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2099
+ // \u2500\u2500 LOD & visibility \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2081
2100
  function updateLOD(k) {
2082
- nodeLabel.style('opacity', k > 0.5 ? Math.min(1, (k - 0.5) * 2) : 0);
2083
- linkLabel.style('opacity', k > 1.2 ? Math.min(1, (k - 1.2) * 3) : 0);
2101
+ if (nodeLabelSel) nodeLabelSel.style('opacity', k > 0.5 ? Math.min(1, (k - 0.5) * 2) : 0);
2102
+ if (linkLabelSel) linkLabelSel.style('opacity', k > 1.2 ? Math.min(1, (k - 1.2) * 3) : 0);
2084
2103
  d3.selectAll('.hull-label').style('font-size', k < 0.4 ? '18px' : '13px');
2085
2104
  }
2086
2105
 
2087
- // \u2500\u2500 Visibility filter \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2088
2106
  function updateVisibility() {
2089
- node.style('display', d => layerVisible[d.layer] ? null : 'none');
2090
- link.style('display', d => {
2091
- const sNode = data.nodes.find(n => n.id === (d.source.id || d.source));
2092
- const tNode = data.nodes.find(n => n.id === (d.target.id || d.target));
2093
- return (sNode && layerVisible[sNode.layer]) && (tNode && layerVisible[tNode.layer]) ? null : 'none';
2107
+ if (!nodeSel) return;
2108
+ nodeSel.style('display', d => layerVisible[d.layer] ? null : 'none');
2109
+ linkSel.style('display', d => {
2110
+ const s = data.nodes.find(n => n.id === (d.source.id||d.source));
2111
+ const t = data.nodes.find(n => n.id === (d.target.id||d.target));
2112
+ return (s && layerVisible[s.layer]) && (t && layerVisible[t.layer]) ? null : 'none';
2094
2113
  });
2095
- linkLabel.style('display', d => {
2096
- const sNode = data.nodes.find(n => n.id === (d.source.id || d.source));
2097
- const tNode = data.nodes.find(n => n.id === (d.target.id || d.target));
2098
- return (sNode && layerVisible[sNode.layer]) && (tNode && layerVisible[tNode.layer]) ? null : 'none';
2114
+ linkLabelSel.style('display', d => {
2115
+ const s = data.nodes.find(n => n.id === (d.source.id||d.source));
2116
+ const t = data.nodes.find(n => n.id === (d.target.id||d.target));
2117
+ return (s && layerVisible[s.layer]) && (t && layerVisible[t.layer]) ? null : 'none';
2099
2118
  });
2100
2119
  }
2101
2120
 
2102
- // \u2500\u2500 Tick \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2103
- sim.on('tick', () => {
2104
- updateHulls();
2105
- link
2106
- .attr('x1', d => d.source.x).attr('y1', d => d.source.y)
2107
- .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
2108
- linkLabel
2109
- .attr('x', d => (d.source.x + d.target.x) / 2)
2110
- .attr('y', d => (d.source.y + d.target.y) / 2 - 4);
2111
- node.attr('transform', d => \`translate(\${d.x},\${d.y})\`);
2112
- });
2121
+ // \u2500\u2500 Init \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2122
+ rebuildGraph();
2123
+ buildNodeList();
2124
+ updateLOD(1);
2113
2125
 
2114
- // Click empty space to deselect
2115
2126
  svgEl.on('click', () => {
2127
+ selectedNodeId = null;
2128
+ d3.selectAll('.node-hex').classed('selected', false);
2129
+ buildNodeList(nodeSearchEl.value);
2116
2130
  sidebar.innerHTML = '<h2>Infrastructure Map</h2><p class="hint">Click a node to view details.</p>';
2117
2131
  });
2118
-
2119
- // Initial LOD
2120
- updateLOD(1);
2121
2132
  </script>
2122
2133
  </body>
2123
2134
  </html>`;
@@ -2251,7 +2262,7 @@ function exportSOPDashboard(sops) {
2251
2262
  <body>
2252
2263
  <div class="header">
2253
2264
  <h1>SOP Dashboard</h1>
2254
- <div class="subtitle">Datasynx Cartography \u2014 Standard Operating Procedures</div>
2265
+ <div class="subtitle">Cartography \u2014 Standard Operating Procedures</div>
2255
2266
  <div class="stats-row">
2256
2267
  <div class="stat-card"><div class="value" id="sop-count">0</div><div class="label">SOPs</div></div>
2257
2268
  <div class="stat-card"><div class="value" id="step-count">0</div><div class="label">Total Steps</div></div>
@@ -2288,7 +2299,7 @@ systems.forEach(([name, count]) => {
2288
2299
 
2289
2300
  const listDiv = document.getElementById('sop-list');
2290
2301
  if (sops.length === 0) {
2291
- listDiv.innerHTML = '<div class="empty">No SOPs found. Start the shadow daemon and observe workflows.</div>';
2302
+ listDiv.innerHTML = '<div class="empty">No SOPs found. Run a discovery session first.</div>';
2292
2303
  }
2293
2304
 
2294
2305
  sops.forEach((sop, i) => {
@@ -2408,20 +2419,8 @@ function checkPrerequisites() {
2408
2419
  process.stderr.write("\u2713 Eingeloggt via claude login (Subscription)\n");
2409
2420
  }
2410
2421
  }
2411
- function checkPollInterval(intervalMs) {
2412
- if (intervalMs < MIN_POLL_INTERVAL_MS) {
2413
- process.stderr.write(
2414
- `\u26A0 Minimum Shadow-Intervall: ${MIN_POLL_INTERVAL_MS / 1e3} Sekunden (Agent SDK Overhead)
2415
- `
2416
- );
2417
- return MIN_POLL_INTERVAL_MS;
2418
- }
2419
- return intervalMs;
2420
- }
2421
2422
  export {
2422
2423
  CartographyDB,
2423
- MIN_POLL_INTERVAL_MS,
2424
- checkPollInterval,
2425
2424
  checkPrerequisites,
2426
2425
  createCartographyTools,
2427
2426
  CartographyDB as default,
@@ -2433,11 +2432,9 @@ export {
2433
2432
  exportSOPDashboard,
2434
2433
  exportSOPMarkdown,
2435
2434
  generateDependencyMermaid,
2436
- generateSOPs,
2437
2435
  generateTopologyMermaid,
2438
2436
  generateWorkflowMermaid,
2439
2437
  runDiscovery,
2440
- runShadowCycle,
2441
2438
  safetyHook,
2442
2439
  stripSensitive
2443
2440
  };