@datasynx/agentic-ai-cartography 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -675,14 +675,14 @@ async function createCartographyTools(db, sessionId, opts = {}) {
675
675
  tool("scan_local_databases", "Scan for local database files and running DB servers \u2014 PostgreSQL databases, MySQL, SQLite files from installed apps", {
676
676
  deep: z.boolean().default(false).optional().describe("Also search home directory recursively for SQLite/DB files (slower)")
677
677
  }, async (args) => {
678
- const { execSync: execSync3 } = await import("child_process");
678
+ const { execSync: execSync2 } = await import("child_process");
679
679
  const { homedir } = await import("os");
680
680
  const { existsSync: existsSync3 } = await import("fs");
681
681
  const deep = args["deep"] ?? false;
682
682
  const HOME = homedir();
683
683
  const run = (cmd) => {
684
684
  try {
685
- return execSync3(cmd, { stdio: "pipe", timeout: 1e4, shell: "/bin/sh" }).toString().trim();
685
+ return execSync2(cmd, { stdio: "pipe", timeout: 1e4, shell: "/bin/sh" }).toString().trim();
686
686
  } catch {
687
687
  return "";
688
688
  }
@@ -709,12 +709,12 @@ ${v}`).join("\n\n");
709
709
  tool("scan_k8s_resources", "Scan Kubernetes cluster via kubectl \u2014 100% readonly (get, describe)", {
710
710
  namespace: z.string().optional().describe("Filter by namespace \u2014 empty = all namespaces")
711
711
  }, async (args) => {
712
- const { execSync: execSync3 } = await import("child_process");
712
+ const { execSync: execSync2 } = await import("child_process");
713
713
  const ns = args["namespace"];
714
714
  const nsFlag = ns ? `-n ${ns}` : "--all-namespaces";
715
715
  const run = (cmd) => {
716
716
  try {
717
- return execSync3(cmd, { stdio: "pipe", timeout: 15e3, shell: "/bin/sh" }).toString().trim();
717
+ return execSync2(cmd, { stdio: "pipe", timeout: 15e3, shell: "/bin/sh" }).toString().trim();
718
718
  } catch (e) {
719
719
  return `(error: ${e instanceof Error ? e.message.split("\n")[0] : String(e)})`;
720
720
  }
@@ -738,7 +738,7 @@ ${run(c)}`).join("\n\n");
738
738
  region: z.string().optional().describe("AWS Region \u2014 default: AWS_DEFAULT_REGION or profile"),
739
739
  profile: z.string().optional().describe("AWS CLI profile")
740
740
  }, async (args) => {
741
- const { execSync: execSync3 } = await import("child_process");
741
+ const { execSync: execSync2 } = await import("child_process");
742
742
  const region = args["region"];
743
743
  const profile = args["profile"];
744
744
  const env = { ...process.env };
@@ -746,7 +746,7 @@ ${run(c)}`).join("\n\n");
746
746
  const pf = profile ? `--profile ${profile}` : "";
747
747
  const run = (cmd) => {
748
748
  try {
749
- return execSync3(cmd, { stdio: "pipe", timeout: 2e4, shell: "/bin/sh", env }).toString().trim();
749
+ return execSync2(cmd, { stdio: "pipe", timeout: 2e4, shell: "/bin/sh", env }).toString().trim();
750
750
  } catch (e) {
751
751
  return `(error: ${e instanceof Error ? e.message.split("\n")[0] : String(e)})`;
752
752
  }
@@ -768,12 +768,12 @@ ${run(c)}`).join("\n\n");
768
768
  tool("scan_gcp_resources", "Scan Google Cloud Platform via gcloud CLI \u2014 100% readonly (list, describe)", {
769
769
  project: z.string().optional().describe("GCP Project ID \u2014 default: current gcloud project")
770
770
  }, async (args) => {
771
- const { execSync: execSync3 } = await import("child_process");
771
+ const { execSync: execSync2 } = await import("child_process");
772
772
  const project = args["project"];
773
773
  const pf = project ? `--project ${project}` : "";
774
774
  const run = (cmd) => {
775
775
  try {
776
- return execSync3(cmd, { stdio: "pipe", timeout: 2e4, shell: "/bin/sh" }).toString().trim();
776
+ return execSync2(cmd, { stdio: "pipe", timeout: 2e4, shell: "/bin/sh" }).toString().trim();
777
777
  } catch (e) {
778
778
  return `(error: ${e instanceof Error ? e.message.split("\n")[0] : String(e)})`;
779
779
  }
@@ -797,14 +797,14 @@ ${run(c)}`).join("\n\n");
797
797
  subscription: z.string().optional().describe("Azure Subscription ID"),
798
798
  resourceGroup: z.string().optional().describe("Filter by resource group")
799
799
  }, async (args) => {
800
- const { execSync: execSync3 } = await import("child_process");
800
+ const { execSync: execSync2 } = await import("child_process");
801
801
  const sub = args["subscription"];
802
802
  const rg = args["resourceGroup"];
803
803
  const sf = sub ? `--subscription ${sub}` : "";
804
804
  const rf = rg ? `--resource-group ${rg}` : "";
805
805
  const run = (cmd) => {
806
806
  try {
807
- return execSync3(cmd, { stdio: "pipe", timeout: 2e4, shell: "/bin/sh" }).toString().trim();
807
+ return execSync2(cmd, { stdio: "pipe", timeout: 2e4, shell: "/bin/sh" }).toString().trim();
808
808
  } catch (e) {
809
809
  return `(error: ${e instanceof Error ? e.message.split("\n")[0] : String(e)})`;
810
810
  }
@@ -827,11 +827,11 @@ ${run(c)}`).join("\n\n");
827
827
  tool("scan_installed_apps", "Scan all installed apps and tools \u2014 IDEs, office, dev tools, business apps, databases", {
828
828
  searchHint: z.string().optional().describe('Optional search term to find specific tools (e.g. "hubspot windsurf cursor")')
829
829
  }, async (args) => {
830
- const { execSync: execSync3 } = await import("child_process");
830
+ const { execSync: execSync2 } = await import("child_process");
831
831
  const hint = args["searchHint"];
832
832
  const run = (cmd) => {
833
833
  try {
834
- return execSync3(cmd, { stdio: "pipe", timeout: 15e3, shell: "/bin/sh" }).toString().trim();
834
+ return execSync2(cmd, { stdio: "pipe", timeout: 15e3, shell: "/bin/sh" }).toString().trim();
835
835
  } catch {
836
836
  return "";
837
837
  }
@@ -1630,525 +1630,6 @@ function exportJSON(db, sessionId) {
1630
1630
  sops
1631
1631
  }, null, 2);
1632
1632
  }
1633
- function exportHTML(nodes, edges) {
1634
- const graphData = JSON.stringify({
1635
- nodes: nodes.map((n) => ({
1636
- id: n.id,
1637
- name: n.name,
1638
- type: n.type,
1639
- layer: nodeLayer(n.type),
1640
- confidence: n.confidence,
1641
- discoveredVia: n.discoveredVia,
1642
- discoveredAt: n.discoveredAt,
1643
- tags: n.tags,
1644
- metadata: n.metadata
1645
- })),
1646
- links: edges.map((e) => ({
1647
- source: e.sourceId,
1648
- target: e.targetId,
1649
- relationship: e.relationship,
1650
- confidence: e.confidence,
1651
- evidence: e.evidence
1652
- }))
1653
- });
1654
- return `<!DOCTYPE html>
1655
- <html lang="en">
1656
- <head>
1657
- <meta charset="UTF-8">
1658
- <title>Cartography \u2014 Infrastructure Map</title>
1659
- <script src="https://d3js.org/d3.v7.min.js"></script>
1660
- <style>
1661
- * { box-sizing: border-box; margin: 0; padding: 0; }
1662
- body { background: #0a0e14; color: #e6edf3; font-family: 'SF Mono','Fira Code','Cascadia Code',monospace; display: flex; overflow: hidden; height: 100vh; }
1663
-
1664
- /* \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 */
1665
- #node-panel {
1666
- width: 220px; min-width: 220px; height: 100vh; overflow: hidden;
1667
- background: #0d1117; border-right: 1px solid #1b2028;
1668
- display: flex; flex-direction: column;
1669
- }
1670
- #node-panel-header {
1671
- padding: 10px 12px 8px; border-bottom: 1px solid #1b2028;
1672
- font-size: 11px; color: #6e7681; text-transform: uppercase; letter-spacing: 0.6px;
1673
- }
1674
- #node-search {
1675
- width: calc(100% - 16px); margin: 8px; padding: 5px 8px;
1676
- background: #161b22; border: 1px solid #30363d; border-radius: 5px;
1677
- color: #e6edf3; font-size: 11px; font-family: inherit; outline: none;
1678
- }
1679
- #node-search:focus { border-color: #58a6ff; }
1680
- #node-list { flex: 1; overflow-y: auto; padding-bottom: 8px; }
1681
- .node-list-item {
1682
- padding: 5px 12px; cursor: pointer; font-size: 11px;
1683
- display: flex; align-items: center; gap: 6px; border-left: 2px solid transparent;
1684
- }
1685
- .node-list-item:hover { background: #161b22; }
1686
- .node-list-item.active { background: #1a2436; border-left-color: #58a6ff; }
1687
- .node-list-dot { width: 7px; height: 7px; border-radius: 2px; flex-shrink: 0; }
1688
- .node-list-name { color: #c9d1d9; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
1689
- .node-list-type { color: #484f58; font-size: 9px; flex-shrink: 0; }
1690
-
1691
- /* \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 */
1692
- #graph { flex: 1; height: 100vh; position: relative; }
1693
- svg { width: 100%; height: 100%; }
1694
- .hull { opacity: 0.12; stroke-width: 1.5; stroke-opacity: 0.25; }
1695
- .hull-label { font-size: 13px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; fill-opacity: 0.5; pointer-events: none; }
1696
- .link { stroke-opacity: 0.4; }
1697
- .link-label { font-size: 8px; fill: #6e7681; pointer-events: none; opacity: 0; }
1698
- .node-hex { stroke-width: 1.8; cursor: pointer; transition: opacity 0.15s; }
1699
- .node-hex:hover { filter: brightness(1.3); stroke-width: 3; }
1700
- .node-hex.selected { stroke-width: 3.5; filter: brightness(1.5); }
1701
- .node-label { font-size: 10px; fill: #c9d1d9; pointer-events: none; opacity: 0; }
1702
-
1703
- /* \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 */
1704
- #sidebar {
1705
- width: 300px; min-width: 300px; height: 100vh; overflow-y: auto;
1706
- background: #0d1117; border-left: 1px solid #1b2028;
1707
- padding: 16px; font-size: 12px; line-height: 1.6;
1708
- }
1709
- #sidebar h2 { margin: 0 0 8px; font-size: 14px; color: #58a6ff; }
1710
- #sidebar .meta-table { width: 100%; border-collapse: collapse; }
1711
- #sidebar .meta-table td { padding: 3px 6px; border-bottom: 1px solid #161b22; vertical-align: top; }
1712
- #sidebar .meta-table td:first-child { color: #6e7681; white-space: nowrap; width: 90px; }
1713
- #sidebar .tag { display: inline-block; background: #161b22; border-radius: 3px; padding: 1px 5px; margin: 1px; font-size: 10px; }
1714
- #sidebar .conf-bar { height: 5px; border-radius: 3px; background: #161b22; margin-top: 3px; }
1715
- #sidebar .conf-fill { height: 100%; border-radius: 3px; }
1716
- #sidebar .edges-list { margin-top: 12px; }
1717
- #sidebar .edge-item { padding: 4px 0; border-bottom: 1px solid #161b22; color: #6e7681; font-size: 11px; }
1718
- #sidebar .edge-item span { color: #c9d1d9; }
1719
- #sidebar .action-row { display: flex; gap: 6px; margin-top: 14px; }
1720
- .btn-delete {
1721
- flex: 1; padding: 6px 10px; background: transparent; border: 1px solid #6e191d;
1722
- color: #f85149; border-radius: 5px; font-size: 11px; font-family: inherit;
1723
- cursor: pointer; text-align: center;
1724
- }
1725
- .btn-delete:hover { background: #3d0c0c; }
1726
- .hint { color: #3d434b; font-size: 11px; margin-top: 8px; }
1727
-
1728
- /* \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 */
1729
- #hud { position: absolute; top: 10px; left: 10px; background: rgba(10,14,20,0.88);
1730
- padding: 10px 14px; border-radius: 8px; font-size: 12px; border: 1px solid #1b2028; pointer-events: none; }
1731
- #hud strong { color: #58a6ff; }
1732
- #hud .stats { color: #6e7681; }
1733
- #hud .zoom-level { color: #3d434b; font-size: 10px; margin-top: 2px; }
1734
-
1735
- /* \u2500\u2500 Toolbar (filters + JGF export) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1736
- #toolbar { position: absolute; top: 10px; right: 10px; display: flex; flex-wrap: wrap; gap: 4px; pointer-events: auto; align-items: center; }
1737
- .filter-btn {
1738
- background: rgba(10,14,20,0.85); border: 1px solid #1b2028; border-radius: 6px;
1739
- color: #c9d1d9; padding: 4px 10px; font-size: 11px; cursor: pointer;
1740
- font-family: inherit; display: flex; align-items: center; gap: 5px;
1741
- }
1742
- .filter-btn:hover { border-color: #30363d; }
1743
- .filter-btn.off { opacity: 0.35; }
1744
- .filter-dot { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
1745
- .export-btn {
1746
- background: rgba(10,14,20,0.85); border: 1px solid #1b2028; border-radius: 6px;
1747
- color: #58a6ff; padding: 4px 12px; font-size: 11px; cursor: pointer;
1748
- font-family: inherit;
1749
- }
1750
- .export-btn:hover { border-color: #58a6ff; background: rgba(88,166,255,0.08); }
1751
- </style>
1752
- </head>
1753
- <body>
1754
-
1755
- <!-- Left: node list panel -->
1756
- <div id="node-panel">
1757
- <div id="node-panel-header">Nodes (${nodes.length})</div>
1758
- <input id="node-search" type="text" placeholder="Search nodes\u2026" autocomplete="off" spellcheck="false">
1759
- <div id="node-list"></div>
1760
- </div>
1761
-
1762
- <!-- Center: graph -->
1763
- <div id="graph">
1764
- <div id="hud">
1765
- <strong>Cartography</strong> &nbsp;
1766
- <span class="stats" id="hud-stats">${nodes.length} nodes \xB7 ${edges.length} edges</span><br>
1767
- <span class="zoom-level">Scroll = zoom \xB7 Drag = pan \xB7 Click = details</span>
1768
- </div>
1769
- <div id="toolbar"></div>
1770
- <svg></svg>
1771
- </div>
1772
-
1773
- <!-- Right: detail sidebar -->
1774
- <div id="sidebar">
1775
- <h2>Infrastructure Map</h2>
1776
- <p class="hint">Click a node to view details.</p>
1777
- </div>
1778
-
1779
- <script>
1780
- const data = ${graphData};
1781
-
1782
- // \u2500\u2500 Color palette per node type \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1783
- const TYPE_COLORS = {
1784
- host: '#4a9eff', database_server: '#ff6b6b', database: '#ff8c42',
1785
- web_service: '#6bcb77', api_endpoint: '#4d96ff', cache_server: '#ffd93d',
1786
- message_broker: '#c77dff', queue: '#e0aaff', topic: '#9d4edd',
1787
- container: '#48cae4', pod: '#00b4d8', k8s_cluster: '#0077b6',
1788
- config_file: '#adb5bd', saas_tool: '#c084fc', table: '#f97316', unknown: '#6c757d',
1789
- };
1790
-
1791
- const LAYER_COLORS = {
1792
- saas: '#c084fc', web: '#6bcb77', data: '#ff6b6b',
1793
- messaging: '#c77dff', infra: '#4a9eff', config: '#adb5bd', other: '#6c757d',
1794
- };
1795
- const LAYER_NAMES = {
1796
- saas: 'SaaS Tools', web: 'Web / API', data: 'Data Layer',
1797
- messaging: 'Messaging', infra: 'Infrastructure', config: 'Config', other: 'Other',
1798
- };
1799
-
1800
- // \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
1801
- const HEX_SIZE = { saas_tool: 16, host: 18, database_server: 18, k8s_cluster: 20, default: 14 };
1802
- function hexSize(d) { return HEX_SIZE[d.type] || HEX_SIZE.default; }
1803
- function hexPath(size) {
1804
- const pts = [];
1805
- for (let i = 0; i < 6; i++) {
1806
- const angle = (Math.PI / 3) * i - Math.PI / 6;
1807
- pts.push([size * Math.cos(angle), size * Math.sin(angle)]);
1808
- }
1809
- return 'M' + pts.map(p => p.join(',')).join('L') + 'Z';
1810
- }
1811
-
1812
- // \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
1813
- const nodeListEl = document.getElementById('node-list');
1814
- const nodeSearchEl = document.getElementById('node-search');
1815
- let selectedNodeId = null;
1816
-
1817
- function buildNodeList(filter) {
1818
- const q = (filter || '').toLowerCase();
1819
- nodeListEl.innerHTML = '';
1820
- const sorted = [...data.nodes].sort((a, b) => a.name.localeCompare(b.name));
1821
- for (const d of sorted) {
1822
- if (q && !d.name.toLowerCase().includes(q) && !d.type.includes(q) && !d.id.toLowerCase().includes(q)) continue;
1823
- const item = document.createElement('div');
1824
- item.className = 'node-list-item' + (d.id === selectedNodeId ? ' active' : '');
1825
- item.dataset.id = d.id;
1826
- const color = TYPE_COLORS[d.type] || '#aaa';
1827
- item.innerHTML = \`<span class="node-list-dot" style="background:\${color}"></span>
1828
- <span class="node-list-name" title="\${d.id}">\${d.name}</span>
1829
- <span class="node-list-type">\${d.type.replace(/_/g,' ')}</span>\`;
1830
- item.onclick = () => { selectNode(d); focusNode(d); };
1831
- nodeListEl.appendChild(item);
1832
- }
1833
- }
1834
-
1835
- nodeSearchEl.addEventListener('input', e => buildNodeList(e.target.value));
1836
-
1837
- // \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
1838
- const sidebar = document.getElementById('sidebar');
1839
-
1840
- function selectNode(d) {
1841
- selectedNodeId = d.id;
1842
- buildNodeList(nodeSearchEl.value);
1843
- showNode(d);
1844
- // highlight hex
1845
- d3.selectAll('.node-hex').classed('selected', nd => nd.id === d.id);
1846
- }
1847
-
1848
- function showNode(d) {
1849
- const c = TYPE_COLORS[d.type] || '#aaa';
1850
- const confPct = Math.round(d.confidence * 100);
1851
- const tags = (d.tags || []).map(t => \`<span class="tag">\${t}</span>\`).join('');
1852
- const metaRows = Object.entries(d.metadata || {})
1853
- .filter(([,v]) => v !== null && v !== undefined && String(v).length > 0)
1854
- .map(([k,v]) => \`<tr><td>\${k}</td><td>\${JSON.stringify(v)}</td></tr>\`)
1855
- .join('');
1856
- const related = data.links.filter(l =>
1857
- (l.source.id||l.source) === d.id || (l.target.id||l.target) === d.id
1858
- );
1859
- const edgeItems = related.map(l => {
1860
- const isOut = (l.source.id||l.source) === d.id;
1861
- const other = isOut ? (l.target.id||l.target) : (l.source.id||l.source);
1862
- return \`<div class="edge-item">\${isOut ? '\u2192' : '\u2190'} <span>\${other}</span> <small>[\${l.relationship}]</small></div>\`;
1863
- }).join('');
1864
-
1865
- sidebar.innerHTML = \`
1866
- <h2>\${d.name}</h2>
1867
- <table class="meta-table">
1868
- <tr><td>ID</td><td style="font-size:10px;word-break:break-all">\${d.id}</td></tr>
1869
- <tr><td>Type</td><td><span style="color:\${c}">\${d.type}</span></td></tr>
1870
- <tr><td>Layer</td><td>\${d.layer}</td></tr>
1871
- <tr><td>Confidence</td><td>
1872
- \${confPct}%
1873
- <div class="conf-bar"><div class="conf-fill" style="width:\${confPct}%;background:\${c}"></div></div>
1874
- </td></tr>
1875
- <tr><td>Discovered via</td><td>\${d.discoveredVia || '\u2014'}</td></tr>
1876
- <tr><td>Timestamp</td><td>\${d.discoveredAt ? d.discoveredAt.substring(0,19).replace('T',' ') : '\u2014'}</td></tr>
1877
- \${tags ? '<tr><td>Tags</td><td>'+tags+'</td></tr>' : ''}
1878
- \${metaRows}
1879
- </table>
1880
- \${related.length > 0 ? '<div class="edges-list"><strong>Connections (' + related.length + '):</strong>'+edgeItems+'</div>' : ''}
1881
- <div class="action-row">
1882
- <button class="btn-delete" onclick="deleteNode('\${d.id}')">\u{1F5D1} Delete node</button>
1883
- </div>
1884
- \`;
1885
- }
1886
-
1887
- // \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
1888
- function deleteNode(id) {
1889
- const idx = data.nodes.findIndex(n => n.id === id);
1890
- if (idx === -1) return;
1891
- data.nodes.splice(idx, 1);
1892
- data.links = data.links.filter(l =>
1893
- (l.source.id || l.source) !== id && (l.target.id || l.target) !== id
1894
- );
1895
- selectedNodeId = null;
1896
- sidebar.innerHTML = '<h2>Infrastructure Map</h2><p class="hint">Node deleted.</p>';
1897
- document.getElementById('hud-stats').textContent =
1898
- data.nodes.length + ' nodes \xB7 ' + data.links.length + ' edges';
1899
- rebuildGraph();
1900
- buildNodeList(nodeSearchEl.value);
1901
- }
1902
-
1903
- // \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
1904
- const svgEl = d3.select('svg');
1905
- const graphDiv = document.getElementById('graph');
1906
- const W = () => graphDiv.clientWidth;
1907
- const H = () => graphDiv.clientHeight;
1908
- const g = svgEl.append('g');
1909
-
1910
- svgEl.append('defs').append('marker')
1911
- .attr('id', 'arrow').attr('viewBox', '0 0 10 6')
1912
- .attr('refX', 10).attr('refY', 3)
1913
- .attr('markerWidth', 8).attr('markerHeight', 6)
1914
- .attr('orient', 'auto')
1915
- .append('path').attr('d', 'M0,0 L10,3 L0,6 Z').attr('fill', '#555');
1916
-
1917
- let currentZoom = 1;
1918
- const zoomBehavior = d3.zoom().scaleExtent([0.08, 6]).on('zoom', e => {
1919
- g.attr('transform', e.transform);
1920
- currentZoom = e.transform.k;
1921
- updateLOD(currentZoom);
1922
- });
1923
- svgEl.call(zoomBehavior);
1924
-
1925
- // \u2500\u2500 Layer filter state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1926
- const layers = [...new Set(data.nodes.map(d => d.layer))];
1927
- const layerVisible = {};
1928
- layers.forEach(l => layerVisible[l] = true);
1929
-
1930
- const toolbarEl = document.getElementById('toolbar');
1931
-
1932
- // Filter buttons
1933
- layers.forEach(layer => {
1934
- const btn = document.createElement('button');
1935
- btn.className = 'filter-btn';
1936
- btn.innerHTML = \`<span class="filter-dot" style="background:\${LAYER_COLORS[layer]||'#666'}"></span>\${LAYER_NAMES[layer]||layer}\`;
1937
- btn.onclick = () => {
1938
- layerVisible[layer] = !layerVisible[layer];
1939
- btn.classList.toggle('off', !layerVisible[layer]);
1940
- updateVisibility();
1941
- };
1942
- toolbarEl.appendChild(btn);
1943
- });
1944
-
1945
- // JGF export button
1946
- const jgfBtn = document.createElement('button');
1947
- jgfBtn.className = 'export-btn';
1948
- jgfBtn.textContent = '\u2193 JGF';
1949
- jgfBtn.title = 'Export JSON Graph Format';
1950
- jgfBtn.onclick = exportJGF;
1951
- toolbarEl.appendChild(jgfBtn);
1952
-
1953
- // \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
1954
- function exportJGF() {
1955
- const jgf = {
1956
- graph: {
1957
- directed: true,
1958
- type: 'cartography',
1959
- label: 'Infrastructure Map',
1960
- metadata: { exportedAt: new Date().toISOString() },
1961
- nodes: Object.fromEntries(data.nodes.map(n => [n.id, {
1962
- label: n.name,
1963
- metadata: { type: n.type, layer: n.layer, confidence: n.confidence,
1964
- discoveredVia: n.discoveredVia, discoveredAt: n.discoveredAt,
1965
- tags: n.tags, ...n.metadata }
1966
- }])),
1967
- edges: data.links.map(l => ({
1968
- source: l.source.id || l.source,
1969
- target: l.target.id || l.target,
1970
- relation: l.relationship,
1971
- metadata: { confidence: l.confidence, evidence: l.evidence }
1972
- })),
1973
- }
1974
- };
1975
- const blob = new Blob([JSON.stringify(jgf, null, 2)], { type: 'application/json' });
1976
- const url = URL.createObjectURL(blob);
1977
- const a = document.createElement('a');
1978
- a.href = url; a.download = 'cartography-graph.jgf.json'; a.click();
1979
- URL.revokeObjectURL(url);
1980
- }
1981
-
1982
- // \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
1983
- function clusterForce(alpha) {
1984
- const centroids = {};
1985
- const counts = {};
1986
- data.nodes.forEach(d => {
1987
- if (!centroids[d.layer]) { centroids[d.layer] = { x: 0, y: 0 }; counts[d.layer] = 0; }
1988
- centroids[d.layer].x += d.x || 0;
1989
- centroids[d.layer].y += d.y || 0;
1990
- counts[d.layer]++;
1991
- });
1992
- for (const l in centroids) { centroids[l].x /= counts[l]; centroids[l].y /= counts[l]; }
1993
- const strength = alpha * 0.15;
1994
- data.nodes.forEach(d => {
1995
- const c = centroids[d.layer];
1996
- if (c) { d.vx += (c.x - d.x) * strength; d.vy += (c.y - d.y) * strength; }
1997
- });
1998
- }
1999
-
2000
- // \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
2001
- const hullGroup = g.append('g').attr('class', 'hulls');
2002
- const hullPaths = {};
2003
- const hullLabels = {};
2004
- layers.forEach(layer => {
2005
- hullPaths[layer] = hullGroup.append('path').attr('class', 'hull')
2006
- .attr('fill', LAYER_COLORS[layer] || '#666').attr('stroke', LAYER_COLORS[layer] || '#666');
2007
- hullLabels[layer] = hullGroup.append('text').attr('class', 'hull-label')
2008
- .attr('fill', LAYER_COLORS[layer] || '#666').text(LAYER_NAMES[layer] || layer);
2009
- });
2010
-
2011
- function updateHulls() {
2012
- layers.forEach(layer => {
2013
- if (!layerVisible[layer]) { hullPaths[layer].attr('d', null); hullLabels[layer].attr('x', -9999); return; }
2014
- const pts = data.nodes.filter(d => d.layer === layer && layerVisible[d.layer]).map(d => [d.x, d.y]);
2015
- if (pts.length < 3) {
2016
- hullPaths[layer].attr('d', null);
2017
- if (pts.length > 0) hullLabels[layer].attr('x', pts[0][0]).attr('y', pts[0][1] - 30);
2018
- else hullLabels[layer].attr('x', -9999);
2019
- return;
2020
- }
2021
- const hull = d3.polygonHull(pts);
2022
- if (!hull) { hullPaths[layer].attr('d', null); return; }
2023
- const cx = d3.mean(hull, p => p[0]);
2024
- const cy = d3.mean(hull, p => p[1]);
2025
- const padded = hull.map(p => {
2026
- const dx = p[0] - cx, dy = p[1] - cy;
2027
- const len = Math.sqrt(dx*dx + dy*dy) || 1;
2028
- return [p[0] + dx/len * 40, p[1] + dy/len * 40];
2029
- });
2030
- hullPaths[layer].attr('d', 'M' + padded.join('L') + 'Z');
2031
- hullLabels[layer].attr('x', cx).attr('y', cy - d3.max(hull, p => Math.abs(p[1] - cy)) - 30);
2032
- });
2033
- }
2034
-
2035
- // \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
2036
- let linkSel, linkLabelSel, nodeSel, nodeLabelSel, sim;
2037
- const linkGroup = g.append('g');
2038
- const nodeGroup = g.append('g');
2039
-
2040
- function focusNode(d) {
2041
- if (!d.x || !d.y) return;
2042
- const w = W(), h = H();
2043
- svgEl.transition().duration(500).call(
2044
- zoomBehavior.transform,
2045
- d3.zoomIdentity.translate(w / 2, h / 2).scale(Math.min(3, currentZoom < 1 ? 1.5 : currentZoom)).translate(-d.x, -d.y)
2046
- );
2047
- }
2048
-
2049
- function rebuildGraph() {
2050
- if (sim) sim.stop();
2051
-
2052
- // Links
2053
- linkSel = linkGroup.selectAll('line').data(data.links, d => \`\${d.source.id||d.source}>\${d.target.id||d.target}\`);
2054
- linkSel.exit().remove();
2055
- const linkEnter = linkSel.enter().append('line').attr('class', 'link');
2056
- linkSel = linkEnter.merge(linkSel)
2057
- .attr('stroke', d => d.confidence < 0.6 ? '#2a2e35' : '#3d434b')
2058
- .attr('stroke-dasharray', d => d.confidence < 0.6 ? '4 3' : null)
2059
- .attr('stroke-width', d => d.confidence < 0.6 ? 0.8 : 1.2)
2060
- .attr('marker-end', 'url(#arrow)');
2061
- linkSel.select('title').remove();
2062
- linkSel.append('title').text(d => \`\${d.relationship} (\${Math.round(d.confidence*100)}%)
2063
- \${d.evidence||''}\`);
2064
-
2065
- // Link labels
2066
- linkLabelSel = linkGroup.selectAll('text').data(data.links, d => \`\${d.source.id||d.source}>\${d.target.id||d.target}\`);
2067
- linkLabelSel.exit().remove();
2068
- linkLabelSel = linkLabelSel.enter().append('text').attr('class', 'link-label').merge(linkLabelSel)
2069
- .text(d => d.relationship);
2070
-
2071
- // Nodes
2072
- nodeSel = nodeGroup.selectAll('g').data(data.nodes, d => d.id);
2073
- nodeSel.exit().remove();
2074
- const nodeEnter = nodeSel.enter().append('g')
2075
- .call(d3.drag()
2076
- .on('start', (e, d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
2077
- .on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
2078
- .on('end', (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; })
2079
- )
2080
- .on('click', (e, d) => { e.stopPropagation(); selectNode(d); });
2081
- nodeEnter.append('path').attr('class', 'node-hex');
2082
- nodeEnter.append('title');
2083
- nodeEnter.append('text').attr('class', 'node-label').attr('text-anchor', 'middle');
2084
-
2085
- nodeSel = nodeEnter.merge(nodeSel);
2086
- nodeSel.select('.node-hex')
2087
- .attr('d', d => hexPath(hexSize(d)))
2088
- .attr('fill', d => TYPE_COLORS[d.type] || '#aaa')
2089
- .attr('stroke', d => { const c = d3.color(TYPE_COLORS[d.type] || '#aaa'); return c ? c.brighter(0.8).formatHex() : '#ccc'; })
2090
- .attr('fill-opacity', d => 0.6 + d.confidence * 0.4)
2091
- .classed('selected', d => d.id === selectedNodeId);
2092
- nodeSel.select('title').text(d => \`\${d.name} (\${d.type})
2093
- conf: \${Math.round(d.confidence*100)}%\`);
2094
- nodeLabelSel = nodeSel.select('.node-label')
2095
- .attr('dy', d => hexSize(d) + 13)
2096
- .text(d => d.name.length > 20 ? d.name.substring(0, 18) + '\u2026' : d.name);
2097
-
2098
- // Simulation
2099
- sim = d3.forceSimulation(data.nodes)
2100
- .force('link', d3.forceLink(data.links).id(d => d.id).distance(d => d.relationship === 'contains' ? 50 : 100).strength(0.4))
2101
- .force('charge', d3.forceManyBody().strength(-280))
2102
- .force('center', d3.forceCenter(W() / 2, H() / 2))
2103
- .force('collision', d3.forceCollide().radius(d => hexSize(d) + 10))
2104
- .force('cluster', clusterForce)
2105
- .on('tick', () => {
2106
- updateHulls();
2107
- linkSel.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
2108
- .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
2109
- linkLabelSel.attr('x', d => (d.source.x + d.target.x) / 2)
2110
- .attr('y', d => (d.source.y + d.target.y) / 2 - 4);
2111
- nodeSel.attr('transform', d => \`translate(\${d.x},\${d.y})\`);
2112
- });
2113
- }
2114
-
2115
- // \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
2116
- function updateLOD(k) {
2117
- if (nodeLabelSel) nodeLabelSel.style('opacity', k > 0.5 ? Math.min(1, (k - 0.5) * 2) : 0);
2118
- if (linkLabelSel) linkLabelSel.style('opacity', k > 1.2 ? Math.min(1, (k - 1.2) * 3) : 0);
2119
- d3.selectAll('.hull-label').style('font-size', k < 0.4 ? '18px' : '13px');
2120
- }
2121
-
2122
- function updateVisibility() {
2123
- if (!nodeSel) return;
2124
- nodeSel.style('display', d => layerVisible[d.layer] ? null : 'none');
2125
- linkSel.style('display', d => {
2126
- const s = data.nodes.find(n => n.id === (d.source.id||d.source));
2127
- const t = data.nodes.find(n => n.id === (d.target.id||d.target));
2128
- return (s && layerVisible[s.layer]) && (t && layerVisible[t.layer]) ? null : 'none';
2129
- });
2130
- linkLabelSel.style('display', d => {
2131
- const s = data.nodes.find(n => n.id === (d.source.id||d.source));
2132
- const t = data.nodes.find(n => n.id === (d.target.id||d.target));
2133
- return (s && layerVisible[s.layer]) && (t && layerVisible[t.layer]) ? null : 'none';
2134
- });
2135
- }
2136
-
2137
- // \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
2138
- rebuildGraph();
2139
- buildNodeList();
2140
- updateLOD(1);
2141
-
2142
- svgEl.on('click', () => {
2143
- selectedNodeId = null;
2144
- d3.selectAll('.node-hex').classed('selected', false);
2145
- buildNodeList(nodeSearchEl.value);
2146
- sidebar.innerHTML = '<h2>Infrastructure Map</h2><p class="hint">Click a node to view details.</p>';
2147
- });
2148
- </script>
2149
- </body>
2150
- </html>`;
2151
- }
2152
1633
  function exportSOPMarkdown(sop) {
2153
1634
  const lines = [
2154
1635
  `# ${sop.title}`,
@@ -2170,12 +1651,32 @@ function exportSOPMarkdown(sop) {
2170
1651
  }
2171
1652
  return lines.join("\n");
2172
1653
  }
2173
- function exportCartographyMap(nodes, edges, options) {
2174
- const mapData = buildMapData(nodes, edges, options);
2175
- const { assets, clusters, connections, meta } = mapData;
1654
+ function exportDiscoveryApp(nodes, edges, options) {
1655
+ const theme = options?.theme ?? "dark";
1656
+ const graphData = JSON.stringify({
1657
+ nodes: nodes.map((n) => ({
1658
+ id: n.id,
1659
+ name: n.name,
1660
+ type: n.type,
1661
+ layer: nodeLayer(n.type),
1662
+ confidence: n.confidence,
1663
+ discoveredVia: n.discoveredVia,
1664
+ discoveredAt: n.discoveredAt,
1665
+ tags: n.tags,
1666
+ metadata: n.metadata
1667
+ })),
1668
+ links: edges.map((e) => ({
1669
+ source: e.sourceId,
1670
+ target: e.targetId,
1671
+ relationship: e.relationship,
1672
+ confidence: e.confidence,
1673
+ evidence: e.evidence
1674
+ }))
1675
+ });
1676
+ const { assets, clusters, connections } = buildMapData(nodes, edges, { theme });
2176
1677
  const isEmpty = assets.length === 0;
2177
1678
  const HEX_SIZE2 = 24;
2178
- const dataJson = JSON.stringify({
1679
+ const mapJson = JSON.stringify({
2179
1680
  assets: assets.map((a) => ({
2180
1681
  id: a.id,
2181
1682
  name: a.name,
@@ -2201,621 +1702,1081 @@ function exportCartographyMap(nodes, edges, options) {
2201
1702
  type: c.type ?? "connection"
2202
1703
  }))
2203
1704
  });
1705
+ const nodeCount = nodes.length;
1706
+ const edgeCount = edges.length;
1707
+ const assetCount = assets.length;
1708
+ const clusterCount = clusters.length;
2204
1709
  return `<!DOCTYPE html>
2205
- <html lang="en">
1710
+ <html lang="en" data-theme="${theme}">
2206
1711
  <head>
2207
1712
  <meta charset="UTF-8"/>
2208
- <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
2209
- <title>Data Cartography Map</title>
1713
+ <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
1714
+ <title>Cartography \u2014 Datasynx Discovery</title>
1715
+ <script src="https://d3js.org/d3.v7.min.js"></script>
2210
1716
  <style>
1717
+ /* \u2500\u2500 CSS Custom Properties \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1718
+ :root{
1719
+ --bg-base:#0f172a;--bg-surface:#1e293b;--bg-elevated:#273148;
1720
+ --border:#334155;--border-dim:#1e293b;
1721
+ --text:#e2e8f0;--text-muted:#94a3b8;--text-dim:#475569;
1722
+ --accent:#3b82f6;--accent-hover:#2563eb;--accent-dim:rgba(59,130,246,.12);
1723
+ }
1724
+ [data-theme="light"]{
1725
+ --bg-base:#f8fafc;--bg-surface:#ffffff;--bg-elevated:#f1f5f9;
1726
+ --border:#e2e8f0;--border-dim:#f1f5f9;
1727
+ --text:#0f172a;--text-muted:#64748b;--text-dim:#94a3b8;
1728
+ --accent:#2563eb;--accent-hover:#1d4ed8;--accent-dim:rgba(37,99,235,.08);
1729
+ }
1730
+
1731
+ /* \u2500\u2500 Reset \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2211
1732
  *{box-sizing:border-box;margin:0;padding:0}
2212
- html,body{width:100%;height:100%;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
2213
- body{display:flex;flex-direction:column;background:${meta.theme === "dark" ? "#0f172a" : "#f8fafc"};color:${meta.theme === "dark" ? "#e2e8f0" : "#1e293b"}}
1733
+ html,body{width:100%;height:100%;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Inter',sans-serif}
1734
+ body{display:flex;flex-direction:column;background:var(--bg-base);color:var(--text)}
1735
+
1736
+ /* \u2500\u2500 Topbar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2214
1737
  #topbar{
2215
- height:48px;display:flex;align-items:center;gap:16px;padding:0 20px;
2216
- background:${meta.theme === "dark" ? "#1e293b" : "#fff"};border-bottom:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};z-index:10;flex-shrink:0;
2217
- }
2218
- #topbar h1{font-size:15px;font-weight:600;letter-spacing:-0.01em}
2219
- #search-box{
2220
- display:flex;align-items:center;gap:8px;background:${meta.theme === "dark" ? "#334155" : "#f1f5f9"};
2221
- border-radius:8px;padding:5px 10px;margin-left:auto;
2222
- }
2223
- #search-box input{
2224
- border:none;background:transparent;font-size:13px;outline:none;width:180px;color:inherit;
2225
- }
2226
- #search-box input::placeholder{color:#94a3b8}
2227
- #main{flex:1;display:flex;overflow:hidden;position:relative}
2228
- #canvas-wrap{flex:1;position:relative;overflow:hidden;cursor:grab}
2229
- #canvas-wrap.dragging{cursor:grabbing}
2230
- #canvas-wrap.connecting{cursor:crosshair}
2231
- canvas{display:block;width:100%;height:100%}
2232
- /* Detail panel */
2233
- #detail-panel{
2234
- width:280px;background:${meta.theme === "dark" ? "#1e293b" : "#fff"};border-left:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};
1738
+ height:56px;display:flex;align-items:center;gap:16px;padding:0 20px;
1739
+ background:var(--bg-surface);border-bottom:1px solid var(--border);z-index:100;flex-shrink:0;
1740
+ }
1741
+ .tb-left{display:flex;align-items:center;gap:10px}
1742
+ .brand-logo{flex-shrink:0}
1743
+ .brand-name{font-size:15px;font-weight:700;color:var(--accent);letter-spacing:-.02em}
1744
+ .brand-product{font-size:14px;font-weight:500;color:var(--text-muted);margin-left:2px}
1745
+ .brand-sep{width:1px;height:24px;background:var(--border);margin:0 6px}
1746
+ .tb-center{display:flex;align-items:center;gap:2px;margin-left:auto;
1747
+ background:var(--bg-elevated);border-radius:8px;padding:3px}
1748
+ .tab-btn{
1749
+ padding:6px 16px;border:none;border-radius:6px;font-size:13px;font-weight:500;
1750
+ cursor:pointer;color:var(--text-muted);background:transparent;font-family:inherit;
1751
+ transition:all .15s;
1752
+ }
1753
+ .tab-btn:hover{color:var(--text)}
1754
+ .tab-btn.active{background:var(--accent);color:#fff;box-shadow:0 1px 3px rgba(0,0,0,.2)}
1755
+ .tb-right{display:flex;align-items:center;gap:8px;margin-left:auto}
1756
+ .tb-search{
1757
+ display:flex;align-items:center;gap:6px;background:var(--bg-elevated);
1758
+ border:1px solid var(--border);border-radius:8px;padding:5px 10px;
1759
+ }
1760
+ .tb-search input{
1761
+ border:none;background:transparent;font-size:13px;outline:none;width:160px;
1762
+ color:var(--text);font-family:inherit;
1763
+ }
1764
+ .tb-search input::placeholder{color:var(--text-dim)}
1765
+ .tb-search svg{flex-shrink:0;color:var(--text-dim)}
1766
+ .icon-btn{
1767
+ width:36px;height:36px;border-radius:8px;border:1px solid var(--border);
1768
+ background:var(--bg-surface);cursor:pointer;display:flex;align-items:center;
1769
+ justify-content:center;color:var(--text-muted);text-decoration:none;transition:all .15s;font-size:16px;
1770
+ }
1771
+ .icon-btn:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-dim)}
1772
+ .tb-stats{font-size:11px;color:var(--text-dim);white-space:nowrap}
1773
+
1774
+ /* \u2500\u2500 Views \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
1775
+ .view{flex:1;display:none;overflow:hidden;position:relative}
1776
+ .view.active{display:flex}
1777
+
1778
+ /* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1779
+ MAP VIEW
1780
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 */
1781
+ #map-wrap{flex:1;position:relative;overflow:hidden;cursor:grab}
1782
+ #map-wrap.dragging{cursor:grabbing}
1783
+ #map-wrap.connecting{cursor:crosshair}
1784
+ #map-wrap canvas{display:block;width:100%;height:100%}
1785
+ #map-detail{
1786
+ width:280px;background:var(--bg-surface);border-left:1px solid var(--border);
2235
1787
  display:flex;flex-direction:column;transform:translateX(100%);
2236
1788
  transition:transform .2s ease;z-index:5;flex-shrink:0;overflow-y:auto;
2237
1789
  }
2238
- #detail-panel.open{transform:translateX(0)}
2239
- #detail-panel .panel-header{
2240
- padding:16px;border-bottom:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};display:flex;align-items:center;gap:10px;
1790
+ #map-detail.open{transform:translateX(0)}
1791
+ #map-detail .panel-header{
1792
+ padding:16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;
2241
1793
  }
2242
- #detail-panel .panel-header h3{font-size:14px;font-weight:600;flex:1;word-break:break-word}
2243
- #detail-panel .close-btn{
1794
+ #map-detail .panel-header h3{font-size:14px;font-weight:600;flex:1;word-break:break-word}
1795
+ .close-btn{
2244
1796
  width:24px;height:24px;border:none;background:transparent;cursor:pointer;
2245
- color:#94a3b8;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:16px;
2246
- }
2247
- #detail-panel .close-btn:hover{background:${meta.theme === "dark" ? "#334155" : "#f1f5f9"}}
2248
- #detail-panel .panel-body{padding:12px 16px;display:flex;flex-direction:column;gap:12px}
2249
- #detail-panel .meta-row{display:flex;flex-direction:column;gap:3px}
2250
- #detail-panel .meta-label{font-size:11px;font-weight:500;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em}
2251
- #detail-panel .meta-value{font-size:13px;word-break:break-all}
2252
- #detail-panel .quality-bar{height:6px;border-radius:3px;background:${meta.theme === "dark" ? "#334155" : "#e2e8f0"};margin-top:4px}
2253
- #detail-panel .quality-fill{height:6px;border-radius:3px;transition:width .3s}
2254
- /* Bottom-left toolbar */
2255
- #toolbar-left{
2256
- position:absolute;bottom:20px;left:20px;display:flex;gap:8px;z-index:10;
2257
- }
2258
- .tb-btn{
2259
- width:40px;height:40px;border-radius:10px;border:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};
2260
- background:${meta.theme === "dark" ? "#1e293b" : "#fff"};box-shadow:0 1px 4px rgba(0,0,0,.08);cursor:pointer;
1797
+ color:var(--text-muted);border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:16px;
1798
+ }
1799
+ .close-btn:hover{background:var(--bg-elevated)}
1800
+ .panel-body{padding:12px 16px;display:flex;flex-direction:column;gap:12px}
1801
+ .meta-row{display:flex;flex-direction:column;gap:3px}
1802
+ .meta-label{font-size:11px;font-weight:500;color:var(--text-dim);text-transform:uppercase;letter-spacing:.05em}
1803
+ .meta-value{font-size:13px;word-break:break-all}
1804
+ .quality-bar{height:6px;border-radius:3px;background:var(--bg-elevated);margin-top:4px}
1805
+ .quality-fill{height:6px;border-radius:3px;transition:width .3s}
1806
+
1807
+ /* Map toolbars */
1808
+ #map-tb-left{position:absolute;bottom:20px;left:20px;display:flex;gap:8px;z-index:10}
1809
+ #map-tb-right{position:absolute;bottom:20px;right:20px;display:flex;flex-direction:column;align-items:flex-end;gap:8px;z-index:10}
1810
+ .tb-tool{
1811
+ width:40px;height:40px;border-radius:10px;border:1px solid var(--border);
1812
+ background:var(--bg-surface);box-shadow:0 1px 4px rgba(0,0,0,.08);cursor:pointer;
2261
1813
  display:flex;align-items:center;justify-content:center;font-size:18px;
2262
- transition:all .15s;color:inherit;
1814
+ transition:all .15s;color:var(--text);font-family:-apple-system,sans-serif;
2263
1815
  }
2264
- .tb-btn:hover{border-color:#94a3b8}
2265
- .tb-btn.active{background:${meta.theme === "dark" ? "#1e3a5f" : "#eff6ff"};border-color:#3b82f6}
2266
- /* Bottom-right toolbar */
2267
- #toolbar-right{
2268
- position:absolute;bottom:20px;right:20px;display:flex;flex-direction:column;
2269
- align-items:flex-end;gap:8px;z-index:10;
2270
- }
2271
- #zoom-controls{display:flex;align-items:center;gap:6px}
1816
+ #btn-all-labels{font-size:14px;font-weight:700;letter-spacing:-.02em}
1817
+ .tb-tool:hover{border-color:var(--text-muted)}
1818
+ .tb-tool.active{background:var(--accent-dim);border-color:var(--accent)}
1819
+ .map-zoom{display:flex;align-items:center;gap:6px}
2272
1820
  .zoom-btn{
2273
- width:34px;height:34px;border-radius:8px;border:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};
2274
- background:${meta.theme === "dark" ? "#1e293b" : "#fff"};cursor:pointer;
2275
- font-size:18px;color:inherit;display:flex;align-items:center;justify-content:center;
2276
- }
2277
- .zoom-btn:hover{background:${meta.theme === "dark" ? "#334155" : "#f1f5f9"}}
2278
- #zoom-pct{font-size:12px;font-weight:500;color:#64748b;min-width:38px;text-align:center}
2279
- #detail-selector{display:flex;flex-direction:column;gap:4px}
2280
- .detail-btn{
2281
- width:34px;height:34px;border-radius:8px;border:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};
2282
- background:${meta.theme === "dark" ? "#1e293b" : "#fff"};cursor:pointer;
2283
- font-size:12px;font-weight:600;color:#64748b;display:flex;align-items:center;justify-content:center;
2284
- }
2285
- .detail-btn:hover{background:${meta.theme === "dark" ? "#334155" : "#f1f5f9"}}
2286
- .detail-btn.active{background:${meta.theme === "dark" ? "#1e3a5f" : "#eff6ff"};border-color:#3b82f6;color:#2563eb}
2287
- #connect-btn{
2288
- width:40px;height:40px;border-radius:10px;border:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};
2289
- background:${meta.theme === "dark" ? "#1e293b" : "#fff"};cursor:pointer;
2290
- font-size:18px;display:flex;align-items:center;justify-content:center;color:inherit;
2291
- }
2292
- #connect-btn.active{background:#fef3c7;border-color:#f59e0b}
2293
- /* Tooltip */
2294
- #tooltip{
2295
- position:fixed;background:#1e293b;color:#fff;border-radius:8px;
2296
- padding:8px 12px;font-size:12px;pointer-events:none;z-index:100;
2297
- display:none;max-width:220px;box-shadow:0 4px 12px rgba(0,0,0,.15);
2298
- }
2299
- #tooltip .tt-name{font-weight:600;margin-bottom:2px}
2300
- #tooltip .tt-domain{color:#94a3b8;font-size:11px}
2301
- #tooltip .tt-quality{font-size:11px;margin-top:2px}
2302
- /* Empty state */
2303
- #empty-state{
2304
- position:absolute;inset:0;display:flex;flex-direction:column;
2305
- align-items:center;justify-content:center;gap:12px;color:#94a3b8;
2306
- }
2307
- #empty-state p{font-size:14px}
2308
- /* Theme toggle */
2309
- #theme-btn{
2310
- width:40px;height:40px;border-radius:10px;border:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};
2311
- background:${meta.theme === "dark" ? "#1e293b" : "#fff"};cursor:pointer;
2312
- font-size:18px;display:flex;align-items:center;justify-content:center;color:inherit;
2313
- }
2314
- /* Dark mode overrides (toggled via JS) */
2315
- body.dark{background:#0f172a;color:#e2e8f0}
2316
- body.dark #topbar{background:#1e293b;border-color:#334155}
2317
- body.dark #search-box{background:#334155}
2318
- body.dark #detail-panel{background:#1e293b;border-color:#334155}
2319
- body.dark .tb-btn,body.dark .zoom-btn,body.dark .detail-btn,body.dark #connect-btn,body.dark #theme-btn{
2320
- background:#1e293b;border-color:#334155;color:#e2e8f0;
2321
- }
2322
- /* Light mode overrides */
2323
- body.light{background:#f8fafc;color:#1e293b}
2324
- body.light #topbar{background:#fff;border-color:#e2e8f0}
2325
- /* Connection hint */
2326
- #connect-hint{
1821
+ width:34px;height:34px;border-radius:8px;border:1px solid var(--border);
1822
+ background:var(--bg-surface);cursor:pointer;font-size:18px;color:var(--text);
1823
+ display:flex;align-items:center;justify-content:center;
1824
+ }
1825
+ .zoom-btn:hover{background:var(--bg-elevated)}
1826
+ #map-zoom-pct{font-size:12px;font-weight:500;color:var(--text-dim);min-width:38px;text-align:center}
1827
+ #map-connect-hint{
2327
1828
  position:absolute;top:12px;left:50%;transform:translateX(-50%);
2328
1829
  background:#fef3c7;border:1px solid #f59e0b;color:#92400e;
2329
1830
  padding:6px 14px;border-radius:20px;font-size:12px;font-weight:500;
2330
1831
  display:none;z-index:20;pointer-events:none;
2331
1832
  }
2332
- /* Screen reader only */
2333
- .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}
1833
+ #map-tooltip{
1834
+ position:fixed;background:var(--bg-surface);color:var(--text);border-radius:8px;
1835
+ padding:8px 12px;font-size:12px;pointer-events:none;z-index:200;
1836
+ display:none;max-width:220px;box-shadow:0 4px 12px rgba(0,0,0,.25);border:1px solid var(--border);
1837
+ }
1838
+ #map-tooltip .tt-name{font-weight:600;margin-bottom:2px}
1839
+ #map-tooltip .tt-domain{color:var(--text-muted);font-size:11px}
1840
+ #map-tooltip .tt-quality{font-size:11px;margin-top:2px}
1841
+ #map-empty{
1842
+ position:absolute;inset:0;display:flex;flex-direction:column;
1843
+ align-items:center;justify-content:center;gap:12px;color:var(--text-muted);
1844
+ }
1845
+ #map-empty p{font-size:14px}
1846
+
1847
+ /* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1848
+ TOPOLOGY VIEW
1849
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 */
1850
+ #topo-panel{
1851
+ width:220px;min-width:220px;height:100%;overflow:hidden;
1852
+ background:var(--bg-surface);border-right:1px solid var(--border);
1853
+ display:flex;flex-direction:column;
1854
+ }
1855
+ #topo-panel-header{
1856
+ padding:10px 12px 8px;border-bottom:1px solid var(--border);
1857
+ font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.6px;
1858
+ }
1859
+ #topo-search{
1860
+ width:calc(100% - 16px);margin:8px;padding:5px 8px;
1861
+ background:var(--bg-elevated);border:1px solid var(--border);border-radius:5px;
1862
+ color:var(--text);font-size:11px;font-family:inherit;outline:none;
1863
+ }
1864
+ #topo-search:focus{border-color:var(--accent)}
1865
+ #topo-list{flex:1;overflow-y:auto;padding-bottom:8px}
1866
+ .topo-item{
1867
+ padding:5px 12px;cursor:pointer;font-size:11px;
1868
+ display:flex;align-items:center;gap:6px;border-left:2px solid transparent;
1869
+ }
1870
+ .topo-item:hover{background:var(--bg-elevated)}
1871
+ .topo-item.active{background:var(--accent-dim);border-left-color:var(--accent)}
1872
+ .topo-dot{width:7px;height:7px;border-radius:2px;flex-shrink:0}
1873
+ .topo-name{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
1874
+ .topo-type{color:var(--text-dim);font-size:9px;flex-shrink:0}
1875
+
1876
+ #topo-graph{flex:1;height:100%;position:relative}
1877
+ #topo-graph svg{width:100%;height:100%}
1878
+ .hull{opacity:.12;stroke-width:1.5;stroke-opacity:.25}
1879
+ .hull-label{font-size:13px;font-weight:700;letter-spacing:1px;text-transform:uppercase;fill-opacity:.5;pointer-events:none}
1880
+ .link{stroke-opacity:.4}
1881
+ .link-label{font-size:8px;fill:var(--text-dim);pointer-events:none;opacity:0}
1882
+ .node-hex{stroke-width:1.8;cursor:pointer;transition:opacity .15s}
1883
+ .node-hex:hover{filter:brightness(1.3);stroke-width:3}
1884
+ .node-hex.selected{stroke-width:3.5;filter:brightness(1.5)}
1885
+ .node-label{font-size:10px;fill:var(--text);pointer-events:none;opacity:0}
1886
+
1887
+ #topo-sidebar{
1888
+ width:300px;min-width:300px;height:100%;overflow-y:auto;
1889
+ background:var(--bg-surface);border-left:1px solid var(--border);
1890
+ padding:16px;font-size:12px;line-height:1.6;
1891
+ }
1892
+ #topo-sidebar h2{margin:0 0 8px;font-size:14px;color:var(--accent)}
1893
+ #topo-sidebar .meta-table{width:100%;border-collapse:collapse}
1894
+ #topo-sidebar .meta-table td{padding:3px 6px;border-bottom:1px solid var(--border-dim);vertical-align:top}
1895
+ #topo-sidebar .meta-table td:first-child{color:var(--text-dim);white-space:nowrap;width:90px}
1896
+ #topo-sidebar .tag{display:inline-block;background:var(--bg-elevated);border-radius:3px;padding:1px 5px;margin:1px;font-size:10px}
1897
+ #topo-sidebar .conf-bar{height:5px;border-radius:3px;background:var(--bg-elevated);margin-top:3px}
1898
+ #topo-sidebar .conf-fill{height:100%;border-radius:3px}
1899
+ #topo-sidebar .edges-list{margin-top:12px}
1900
+ #topo-sidebar .edge-item{padding:4px 0;border-bottom:1px solid var(--border-dim);color:var(--text-dim);font-size:11px}
1901
+ #topo-sidebar .edge-item span{color:var(--text)}
1902
+ .hint{color:var(--text-dim);font-size:11px;margin-top:8px}
1903
+
1904
+ #topo-hud{
1905
+ position:absolute;top:10px;left:10px;background:rgba(15,23,42,.88);
1906
+ padding:10px 14px;border-radius:8px;font-size:12px;border:1px solid var(--border);pointer-events:none;
1907
+ }
1908
+ #topo-hud strong{color:var(--accent)}
1909
+ #topo-hud .stats{color:var(--text-dim)}
1910
+ #topo-hud .zoom-level{color:var(--text-dim);font-size:10px;margin-top:2px}
1911
+
1912
+ #topo-toolbar{position:absolute;top:10px;right:10px;display:flex;flex-wrap:wrap;gap:4px;pointer-events:auto;align-items:center}
1913
+ .filter-btn{
1914
+ background:rgba(15,23,42,.85);border:1px solid var(--border);border-radius:6px;
1915
+ color:var(--text);padding:4px 10px;font-size:11px;cursor:pointer;
1916
+ font-family:inherit;display:flex;align-items:center;gap:5px;
1917
+ }
1918
+ .filter-btn:hover{border-color:var(--text-dim)}
1919
+ .filter-btn.off{opacity:.35}
1920
+ .filter-dot{width:8px;height:8px;border-radius:2px;display:inline-block}
1921
+ .export-btn{
1922
+ background:rgba(15,23,42,.85);border:1px solid var(--border);border-radius:6px;
1923
+ color:var(--accent);padding:4px 12px;font-size:11px;cursor:pointer;font-family:inherit;
1924
+ }
1925
+ .export-btn:hover{border-color:var(--accent);background:var(--accent-dim)}
2334
1926
  </style>
2335
1927
  </head>
2336
- <body class="${meta.theme}">
2337
- <!-- Top bar -->
2338
- <div id="topbar">
2339
- <h1>Data Cartography Map</h1>
2340
- <div id="search-box">
2341
- <span style="color:#94a3b8;font-size:14px">&#8981;</span>
2342
- <input id="search-input" type="text" placeholder="Search assets..." aria-label="Search data assets"/>
1928
+ <body>
1929
+
1930
+ <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1931
+ TOPBAR
1932
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->
1933
+ <header id="topbar">
1934
+ <div class="tb-left">
1935
+ <svg class="brand-logo" width="32" height="32" viewBox="0 0 32 32" fill="none">
1936
+ <path d="M16 1.5L29.5 8.75V23.25L16 30.5L2.5 23.25V8.75L16 1.5Z" fill="#0F2347" stroke="#2563EB" stroke-width="1.2"/>
1937
+ <circle cx="10" cy="16" r="2.8" fill="#60A5FA"/><circle cx="22" cy="10.5" r="2.2" fill="#38BDF8"/>
1938
+ <circle cx="22" cy="21.5" r="2.2" fill="#38BDF8"/>
1939
+ <line x1="12.5" y1="14.8" x2="19.8" y2="11.2" stroke="#93C5FD" stroke-width="1.2"/>
1940
+ <line x1="12.5" y1="17.2" x2="19.8" y2="20.8" stroke="#93C5FD" stroke-width="1.2"/>
1941
+ <line x1="22" y1="12.7" x2="22" y2="19.3" stroke="#93C5FD" stroke-width="1" stroke-dasharray="2 1.5"/>
1942
+ </svg>
1943
+ <span class="brand-name">datasynx</span>
1944
+ <span class="brand-sep"></span>
1945
+ <span class="brand-product">Cartography</span>
2343
1946
  </div>
2344
- <button id="theme-btn" title="Toggle dark/light mode" aria-label="Toggle theme">${meta.theme === "dark" ? "&#9788;" : "&#9790;"}</button>
2345
- </div>
2346
- <!-- SR summary -->
2347
- <div class="sr-only" role="status" aria-live="polite" id="sr-summary">
2348
- Data cartography map with ${assets.length} assets in ${clusters.length} clusters.
2349
- </div>
2350
- <!-- Main area -->
2351
- <div id="main">
2352
- <div id="canvas-wrap" role="application" aria-label="Data cartography hex map" tabindex="0">
1947
+ <div class="tb-center">
1948
+ <button class="tab-btn active" id="tab-map-btn" data-tab="map">Map</button>
1949
+ <button class="tab-btn" id="tab-topo-btn" data-tab="topo">Topology</button>
1950
+ </div>
1951
+ <div class="tb-right">
1952
+ <span class="tb-stats">${nodeCount} nodes &middot; ${edgeCount} edges</span>
1953
+ <div class="tb-search">
1954
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1955
+ <circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
1956
+ </svg>
1957
+ <input id="global-search" type="text" placeholder="Search..." autocomplete="off" spellcheck="false"/>
1958
+ </div>
1959
+ <a href="https://www.linkedin.com/company/datasynx-ai/" target="_blank" rel="noopener noreferrer"
1960
+ class="icon-btn" title="Datasynx on LinkedIn" aria-label="LinkedIn">
1961
+ <svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
1962
+ <path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
1963
+ </svg>
1964
+ </a>
1965
+ <button id="theme-btn" class="icon-btn" title="Toggle theme" aria-label="Toggle theme">
1966
+ ${theme === "dark" ? "&#9788;" : "&#9790;"}
1967
+ </button>
1968
+ </div>
1969
+ </header>
1970
+
1971
+ <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
1972
+ MAP VIEW
1973
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->
1974
+ <div id="view-map" class="view active">
1975
+ <div id="map-wrap" tabindex="0" aria-label="Data cartography hex map">
2353
1976
  <canvas id="hexmap" aria-hidden="true"></canvas>
2354
- ${isEmpty ? '<div id="empty-state"><p style="font-size:48px">&#128506;</p><p>No data assets available</p><p style="font-size:12px">Run <code>datasynx-cartography discover</code> to populate the map</p></div>' : ""}
1977
+ ${isEmpty ? '<div id="map-empty"><p style="font-size:48px">&#128506;</p><p>No data assets discovered yet</p><p style="font-size:12px">Run <code>datasynx-cartography discover</code> to populate the map</p></div>' : ""}
2355
1978
  </div>
2356
- <div id="detail-panel" role="complementary" aria-label="Asset details">
1979
+ <div id="map-detail">
2357
1980
  <div class="panel-header">
2358
- <h3 id="dp-name">&mdash;</h3>
2359
- <button class="close-btn" id="dp-close" aria-label="Close panel">&#10005;</button>
1981
+ <h3 id="md-name">&mdash;</h3>
1982
+ <button class="close-btn" id="md-close" aria-label="Close">&#10005;</button>
2360
1983
  </div>
2361
- <div class="panel-body" id="dp-body"></div>
1984
+ <div class="panel-body" id="md-body"></div>
2362
1985
  </div>
2363
- </div>
2364
- <!-- Bottom-left toolbar -->
2365
- <div id="toolbar-left">
2366
- <button class="tb-btn active" id="btn-labels" title="Show labels" aria-pressed="true" aria-label="Toggle labels">&#127991;</button>
2367
- <button class="tb-btn" id="btn-quality" title="Quality layer" aria-pressed="false" aria-label="Toggle quality layer">&#128065;</button>
2368
- </div>
2369
- <!-- Bottom-right toolbar -->
2370
- <div id="toolbar-right">
2371
- <div id="zoom-controls">
2372
- <button class="zoom-btn" id="zoom-out" aria-label="Zoom out">&minus;</button>
2373
- <span id="zoom-pct">100%</span>
2374
- <button class="zoom-btn" id="zoom-in" aria-label="Zoom in">+</button>
1986
+ <div id="map-tb-left">
1987
+ <button class="tb-tool active" id="btn-labels" title="Toggle labels">&#127991;</button>
1988
+ <button class="tb-tool" id="btn-all-labels" title="Show all hex labels">Aa</button>
1989
+ <button class="tb-tool" id="btn-quality" title="Quality layer">&#128065;</button>
1990
+ <button class="tb-tool" id="btn-connect" title="Connection tool">&#128279;</button>
2375
1991
  </div>
2376
- <div id="detail-selector">
2377
- <button class="detail-btn" id="dl-1" aria-label="Detail level 1">1</button>
2378
- <button class="detail-btn active" id="dl-2" aria-label="Detail level 2">2</button>
2379
- <button class="detail-btn" id="dl-3" aria-label="Detail level 3">3</button>
2380
- <button class="detail-btn" id="dl-4" aria-label="Detail level 4">4</button>
1992
+ <div id="map-tb-right">
1993
+ <div class="map-zoom">
1994
+ <button class="zoom-btn" id="mz-out">&minus;</button>
1995
+ <span id="map-zoom-pct">100%</span>
1996
+ <button class="zoom-btn" id="mz-in">+</button>
1997
+ </div>
2381
1998
  </div>
2382
- <button id="connect-btn" title="Connection tool" aria-label="Toggle connection tool">&#128279;</button>
1999
+ <div id="map-connect-hint">Click two assets to create a connection</div>
2000
+ <div id="map-tooltip"><div class="tt-name" id="mtt-name"></div><div class="tt-domain" id="mtt-domain"></div><div class="tt-quality" id="mtt-quality"></div></div>
2383
2001
  </div>
2384
- <!-- Connection hint -->
2385
- <div id="connect-hint">Click two assets to create a connection</div>
2386
- <!-- Tooltip -->
2387
- <div id="tooltip" role="tooltip">
2388
- <div class="tt-name" id="tt-name"></div>
2389
- <div class="tt-domain" id="tt-domain"></div>
2390
- <div class="tt-quality" id="tt-quality"></div>
2002
+
2003
+ <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2004
+ TOPOLOGY VIEW
2005
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->
2006
+ <div id="view-topo" class="view">
2007
+ <div id="topo-panel">
2008
+ <div id="topo-panel-header">Nodes (${nodeCount})</div>
2009
+ <input id="topo-search" type="text" placeholder="Search nodes\u2026" autocomplete="off" spellcheck="false"/>
2010
+ <div id="topo-list"></div>
2011
+ </div>
2012
+ <div id="topo-graph">
2013
+ <div id="topo-hud">
2014
+ <strong>Topology</strong>&nbsp;
2015
+ <span class="stats">${nodeCount} nodes &middot; ${edgeCount} edges</span><br/>
2016
+ <span class="zoom-level">Scroll = zoom &middot; Drag = pan &middot; Click = details</span>
2017
+ </div>
2018
+ <div id="topo-toolbar"></div>
2019
+ <svg></svg>
2020
+ </div>
2021
+ <div id="topo-sidebar">
2022
+ <h2>Infrastructure Map</h2>
2023
+ <p class="hint">Click a node to view details.</p>
2024
+ </div>
2391
2025
  </div>
2392
2026
 
2393
2027
  <script>
2394
- (function() {
2395
- 'use strict';
2396
-
2397
- // \u2500\u2500 Data \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2398
- const MAP = ${dataJson};
2399
- const HEX_SIZE = ${HEX_SIZE2};
2400
- const IS_EMPTY = ${isEmpty};
2401
-
2402
- // Build asset index
2403
- const assetIndex = new Map();
2404
- const clusterByAsset = new Map();
2405
- for (const c of MAP.clusters) {
2406
- for (const aid of c.assetIds) {
2407
- clusterByAsset.set(aid, c);
2408
- }
2409
- }
2410
- for (const a of MAP.assets) {
2411
- assetIndex.set(a.id, a);
2412
- }
2413
-
2414
- // \u2500\u2500 Canvas \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2415
- const canvas = document.getElementById('hexmap');
2416
- const ctx = canvas.getContext('2d');
2417
- const wrap = document.getElementById('canvas-wrap');
2418
- let W = 0, H = 0;
2419
-
2420
- function resize() {
2421
- const dpr = window.devicePixelRatio || 1;
2422
- W = wrap.clientWidth; H = wrap.clientHeight;
2423
- canvas.width = W * dpr; canvas.height = H * dpr;
2424
- canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
2425
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2426
- draw();
2427
- }
2428
- window.addEventListener('resize', resize);
2429
-
2430
- // \u2500\u2500 Viewport \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2431
- let vx = 0, vy = 0, scale = 1;
2432
- let detailLevel = 2, showLabels = true, showQuality = false;
2433
- let isDark = document.body.classList.contains('dark');
2434
- let connectMode = false, connectFirst = null;
2435
- let hoveredAssetId = null, selectedAssetId = null;
2436
- let searchQuery = '';
2437
- let localConnections = [...MAP.connections];
2438
-
2439
- // Flat-top hex math
2440
- function htp_x(q, r) { return HEX_SIZE * (3/2 * q); }
2441
- function htp_y(q, r) { return HEX_SIZE * (Math.sqrt(3)/2 * q + Math.sqrt(3) * r); }
2442
- function w2s(wx, wy) { return { x: wx*scale+vx, y: wy*scale+vy }; }
2443
- function s2w(sx, sy) { return { x: (sx-vx)/scale, y: (sy-vy)/scale }; }
2444
-
2445
- function fitToView() {
2446
- if (IS_EMPTY || MAP.assets.length === 0) { vx = 0; vy = 0; scale = 1; return; }
2447
- let mnx=Infinity,mny=Infinity,mxx=-Infinity,mxy=-Infinity;
2448
- for (const a of MAP.assets) {
2449
- const px=htp_x(a.q,a.r), py=htp_y(a.q,a.r);
2450
- if(px<mnx)mnx=px;if(py<mny)mny=py;if(px>mxx)mxx=px;if(py>mxy)mxy=py;
2451
- }
2452
- const pw=mxx-mnx+HEX_SIZE*4, ph=mxy-mny+HEX_SIZE*4;
2453
- scale = Math.min(W/pw, H/ph, 2) * 0.85;
2454
- vx = W/2 - ((mnx+mxx)/2)*scale;
2455
- vy = H/2 - ((mny+mxy)/2)*scale;
2456
- }
2457
-
2458
- // \u2500\u2500 Drawing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2459
- function hexPath(cx, cy, r) {
2460
- ctx.beginPath();
2461
- for (let i=0;i<6;i++) {
2462
- const angle = Math.PI/180*(60*i);
2463
- const x=cx+r*Math.cos(angle), y=cy+r*Math.sin(angle);
2464
- i===0?ctx.moveTo(x,y):ctx.lineTo(x,y);
2465
- }
2466
- ctx.closePath();
2467
- }
2468
-
2469
- function shadeV(hex, amt) {
2470
- if(!hex||hex.length<7)return hex;
2471
- const n=parseInt(hex.replace('#',''),16);
2472
- const r=Math.min(255,(n>>16)+amt), g=Math.min(255,((n>>8)&0xff)+amt), b=Math.min(255,(n&0xff)+amt);
2473
- return '#'+r.toString(16).padStart(2,'0')+g.toString(16).padStart(2,'0')+b.toString(16).padStart(2,'0');
2474
- }
2475
-
2476
- function draw() {
2477
- ctx.clearRect(0,0,W,H);
2478
- ctx.fillStyle = isDark ? '#0f172a' : '#f8fafc';
2479
- ctx.fillRect(0,0,W,H);
2480
- if (IS_EMPTY) return;
2481
-
2482
- const size = HEX_SIZE * scale;
2483
- const matchedIds = getSearchMatches();
2484
- const hasSearch = searchQuery.length > 0;
2485
-
2486
- // Draw connections
2487
- ctx.save();
2488
- ctx.strokeStyle = isDark ? 'rgba(148,163,184,0.35)' : 'rgba(100,116,139,0.25)';
2489
- ctx.lineWidth = 1.5;
2490
- ctx.setLineDash([4,4]);
2491
- for (const conn of localConnections) {
2492
- const src = assetIndex.get(conn.sourceAssetId);
2493
- const tgt = assetIndex.get(conn.targetAssetId);
2494
- if (!src||!tgt) continue;
2495
- const sp=w2s(htp_x(src.q,src.r),htp_y(src.q,src.r));
2496
- const tp=w2s(htp_x(tgt.q,tgt.r),htp_y(tgt.q,tgt.r));
2497
- ctx.beginPath();ctx.moveTo(sp.x,sp.y);ctx.lineTo(tp.x,tp.y);ctx.stroke();
2498
- }
2499
- ctx.setLineDash([]);
2500
- ctx.restore();
2501
-
2502
- // Draw hexagons per cluster
2503
- for (const cluster of MAP.clusters) {
2504
- const baseColor = cluster.color;
2505
- const clusterAssets = cluster.assetIds.map(id=>assetIndex.get(id)).filter(Boolean);
2506
- const isClusterMatch = !hasSearch || clusterAssets.some(a => matchedIds.has(a.id));
2507
- const clusterDim = hasSearch && !isClusterMatch;
2508
-
2509
- for (let ai=0; ai<clusterAssets.length; ai++) {
2510
- const asset = clusterAssets[ai];
2511
- const wx=htp_x(asset.q,asset.r), wy=htp_y(asset.q,asset.r);
2512
- const s=w2s(wx,wy);
2513
- const cx=s.x, cy=s.y;
2514
-
2515
- // Frustum cull
2516
- if(cx+size<0||cx-size>W||cy+size<0||cy-size>H) continue;
2517
-
2518
- // Shade variation
2519
- const shade = ai%3===0?18:ai%3===1?8:0;
2520
- let fillColor = shadeV(baseColor, shade);
2521
-
2522
- // Quality overlay
2523
- if (showQuality && asset.qualityScore !== null && asset.qualityScore !== undefined) {
2524
- const q = asset.qualityScore;
2525
- if (q < 40) fillColor = '#ef4444';
2526
- else if (q < 70) fillColor = '#f97316';
2527
- }
2028
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2029
+ // SHARED STATE
2030
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2031
+ let isDark = document.documentElement.getAttribute('data-theme') === 'dark';
2032
+ let currentTab = 'map';
2033
+ let topoInited = false;
2034
+
2035
+ // \u2500\u2500 Theme toggle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2036
+ document.getElementById('theme-btn').addEventListener('click', function() {
2037
+ isDark = !isDark;
2038
+ document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
2039
+ this.innerHTML = isDark ? '\\u2606' : '\\u263E';
2040
+ if (typeof drawMap === 'function') drawMap();
2041
+ });
2528
2042
 
2529
- const alpha = clusterDim ? 0.18 : 1;
2530
- const isHovered = asset.id === hoveredAssetId;
2531
- const isSelected = asset.id === selectedAssetId;
2532
- const isConnectFirst = asset.id === connectFirst;
2043
+ // \u2500\u2500 Tab switching \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2044
+ document.querySelectorAll('.tab-btn').forEach(function(btn) {
2045
+ btn.addEventListener('click', function() {
2046
+ var tab = this.getAttribute('data-tab');
2047
+ if (tab === currentTab) return;
2048
+ currentTab = tab;
2049
+ document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
2050
+ this.classList.add('active');
2051
+ document.querySelectorAll('.view').forEach(function(v) { v.classList.remove('active'); });
2052
+ document.getElementById('view-' + tab).classList.add('active');
2053
+ if (tab === 'topo' && !topoInited) { initTopology(); topoInited = true; }
2054
+ if (tab === 'map' && typeof drawMap === 'function') { resizeMap(); }
2055
+ });
2056
+ });
2533
2057
 
2534
- ctx.save();
2535
- ctx.globalAlpha = alpha;
2536
- hexPath(cx, cy, size*0.92);
2058
+ // \u2500\u2500 Global search \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2059
+ document.getElementById('global-search').addEventListener('input', function(e) {
2060
+ var q = e.target.value.trim();
2061
+ if (typeof setMapSearch === 'function') setMapSearch(q);
2062
+ if (typeof setTopoSearch === 'function') setTopoSearch(q);
2063
+ });
2537
2064
 
2538
- if (isDark && (isHovered||isSelected||isConnectFirst)) {
2539
- ctx.shadowColor = fillColor;
2540
- ctx.shadowBlur = isSelected ? 16 : 8;
2541
- }
2065
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2066
+ // MAP VIEW
2067
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2068
+ var MAP = ${mapJson};
2069
+ var MAP_HEX = ${HEX_SIZE2};
2070
+ var MAP_EMPTY = ${isEmpty};
2071
+
2072
+ var mapAssetIndex = new Map();
2073
+ var mapClusterByAsset = new Map();
2074
+ for (var ci = 0; ci < MAP.clusters.length; ci++) {
2075
+ var c = MAP.clusters[ci];
2076
+ for (var ai = 0; ai < c.assetIds.length; ai++) mapClusterByAsset.set(c.assetIds[ai], c);
2077
+ }
2078
+ for (var ni = 0; ni < MAP.assets.length; ni++) mapAssetIndex.set(MAP.assets[ni].id, MAP.assets[ni]);
2079
+
2080
+ var mapCanvas = document.getElementById('hexmap');
2081
+ var mapCtx = mapCanvas.getContext('2d');
2082
+ var mapWrap = document.getElementById('map-wrap');
2083
+ var mW = 0, mH = 0;
2084
+ var mvx = 0, mvy = 0, mScale = 1;
2085
+ var mDetailLevel = 2, mShowLabels = true, mShowQuality = false, mShowAllLabels = false;
2086
+ var mConnectMode = false, mConnectFirst = null;
2087
+ var mHoveredId = null, mSelectedId = null;
2088
+ var mSearchQuery = '';
2089
+ var mLocalConns = MAP.connections.slice();
2090
+
2091
+ function setMapSearch(q) { mSearchQuery = q; drawMap(); }
2092
+
2093
+ function resizeMap() {
2094
+ var dpr = window.devicePixelRatio || 1;
2095
+ mW = mapWrap.clientWidth; mH = mapWrap.clientHeight;
2096
+ mapCanvas.width = mW * dpr; mapCanvas.height = mH * dpr;
2097
+ mapCanvas.style.width = mW + 'px'; mapCanvas.style.height = mH + 'px';
2098
+ mapCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
2099
+ drawMap();
2100
+ }
2101
+ window.addEventListener('resize', function() { if (currentTab === 'map') resizeMap(); });
2102
+
2103
+ function mHtp_x(q, r) { return MAP_HEX * (1.5 * q); }
2104
+ function mHtp_y(q, r) { return MAP_HEX * (Math.sqrt(3) / 2 * q + Math.sqrt(3) * r); }
2105
+ function mW2s(wx, wy) { return { x: wx * mScale + mvx, y: wy * mScale + mvy }; }
2106
+ function mS2w(sx, sy) { return { x: (sx - mvx) / mScale, y: (sy - mvy) / mScale }; }
2107
+
2108
+ function mapFitToView() {
2109
+ if (MAP_EMPTY || MAP.assets.length === 0) { mvx = 0; mvy = 0; mScale = 1; return; }
2110
+ var mnx = Infinity, mny = Infinity, mxx = -Infinity, mxy = -Infinity;
2111
+ for (var i = 0; i < MAP.assets.length; i++) {
2112
+ var a = MAP.assets[i], px = mHtp_x(a.q, a.r), py = mHtp_y(a.q, a.r);
2113
+ if (px < mnx) mnx = px; if (py < mny) mny = py; if (px > mxx) mxx = px; if (py > mxy) mxy = py;
2114
+ }
2115
+ var pw = mxx - mnx + MAP_HEX * 4, ph = mxy - mny + MAP_HEX * 4;
2116
+ mScale = Math.min(mW / pw, mH / ph, 2) * 0.85;
2117
+ mvx = mW / 2 - ((mnx + mxx) / 2) * mScale;
2118
+ mvy = mH / 2 - ((mny + mxy) / 2) * mScale;
2119
+ }
2120
+
2121
+ function mHexPath(cx, cy, r) {
2122
+ mapCtx.beginPath();
2123
+ for (var i = 0; i < 6; i++) {
2124
+ var angle = Math.PI / 180 * (60 * i);
2125
+ var x = cx + r * Math.cos(angle), y = cy + r * Math.sin(angle);
2126
+ i === 0 ? mapCtx.moveTo(x, y) : mapCtx.lineTo(x, y);
2127
+ }
2128
+ mapCtx.closePath();
2129
+ }
2130
+
2131
+ function mShadeV(hex, amt) {
2132
+ if (!hex || hex.length < 7) return hex;
2133
+ var n = parseInt(hex.replace('#', ''), 16);
2134
+ var r = Math.min(255, (n >> 16) + amt), g = Math.min(255, ((n >> 8) & 0xff) + amt), b = Math.min(255, (n & 0xff) + amt);
2135
+ return '#' + r.toString(16).padStart(2, '0') + g.toString(16).padStart(2, '0') + b.toString(16).padStart(2, '0');
2136
+ }
2137
+
2138
+ function mGetSearchMatches() {
2139
+ if (!mSearchQuery) return new Set();
2140
+ var q = mSearchQuery.toLowerCase(), m = new Set();
2141
+ for (var i = 0; i < MAP.assets.length; i++) {
2142
+ var a = MAP.assets[i];
2143
+ if (a.name.toLowerCase().includes(q) || (a.domain && a.domain.toLowerCase().includes(q)) ||
2144
+ (a.subDomain && a.subDomain.toLowerCase().includes(q))) m.add(a.id);
2145
+ }
2146
+ return m;
2147
+ }
2542
2148
 
2543
- ctx.fillStyle = fillColor;
2544
- ctx.fill();
2545
-
2546
- if (isSelected||isConnectFirst) {
2547
- ctx.strokeStyle = isConnectFirst ? '#f59e0b' : '#fff';
2548
- ctx.lineWidth = 2.5;
2549
- ctx.stroke();
2550
- } else if (isHovered) {
2551
- ctx.strokeStyle = isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.2)';
2552
- ctx.lineWidth = 1.5;
2553
- ctx.stroke();
2554
- } else {
2555
- ctx.strokeStyle = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.4)';
2556
- ctx.lineWidth = 1;
2557
- ctx.stroke();
2149
+ function mDrawPill(x, y, text, color, fontSize) {
2150
+ if (!text) return;
2151
+ mapCtx.save();
2152
+ mapCtx.font = '600 ' + fontSize + 'px -apple-system,sans-serif';
2153
+ var tw = mapCtx.measureText(text).width;
2154
+ var ph = fontSize + 8, pw = tw + 20;
2155
+ mapCtx.beginPath();
2156
+ if (mapCtx.roundRect) mapCtx.roundRect(x - pw / 2, y - ph / 2, pw, ph, ph / 2);
2157
+ else mapCtx.rect(x - pw / 2, y - ph / 2, pw, ph);
2158
+ mapCtx.fillStyle = isDark ? 'rgba(30,41,59,0.9)' : 'rgba(255,255,255,0.92)';
2159
+ mapCtx.shadowColor = 'rgba(0,0,0,0.15)'; mapCtx.shadowBlur = 6;
2160
+ mapCtx.fill(); mapCtx.shadowBlur = 0;
2161
+ mapCtx.fillStyle = isDark ? '#e2e8f0' : '#0f172a';
2162
+ mapCtx.textAlign = 'center'; mapCtx.textBaseline = 'middle';
2163
+ mapCtx.fillText(text, x, y);
2164
+ mapCtx.restore();
2165
+ }
2166
+
2167
+ function drawMap() {
2168
+ mapCtx.clearRect(0, 0, mW, mH);
2169
+ var bg = getComputedStyle(document.documentElement).getPropertyValue('--bg-base').trim();
2170
+ mapCtx.fillStyle = bg || (isDark ? '#0f172a' : '#f8fafc');
2171
+ mapCtx.fillRect(0, 0, mW, mH);
2172
+ if (MAP_EMPTY) return;
2173
+
2174
+ var size = MAP_HEX * mScale;
2175
+ var matchedIds = mGetSearchMatches();
2176
+ var hasSearch = mSearchQuery.length > 0;
2177
+
2178
+ // Connections
2179
+ mapCtx.save();
2180
+ mapCtx.strokeStyle = isDark ? 'rgba(148,163,184,0.35)' : 'rgba(100,116,139,0.25)';
2181
+ mapCtx.lineWidth = 1.5; mapCtx.setLineDash([4, 4]);
2182
+ for (var ci = 0; ci < mLocalConns.length; ci++) {
2183
+ var conn = mLocalConns[ci];
2184
+ var src = mapAssetIndex.get(conn.sourceAssetId), tgt = mapAssetIndex.get(conn.targetAssetId);
2185
+ if (!src || !tgt) continue;
2186
+ var sp = mW2s(mHtp_x(src.q, src.r), mHtp_y(src.q, src.r));
2187
+ var tp = mW2s(mHtp_x(tgt.q, tgt.r), mHtp_y(tgt.q, tgt.r));
2188
+ mapCtx.beginPath(); mapCtx.moveTo(sp.x, sp.y); mapCtx.lineTo(tp.x, tp.y); mapCtx.stroke();
2189
+ }
2190
+ mapCtx.setLineDash([]); mapCtx.restore();
2191
+
2192
+ // Hexagons per cluster
2193
+ for (var cli = 0; cli < MAP.clusters.length; cli++) {
2194
+ var cluster = MAP.clusters[cli];
2195
+ var baseColor = cluster.color;
2196
+ var clusterAssets = cluster.assetIds.map(function(id) { return mapAssetIndex.get(id); }).filter(Boolean);
2197
+ var isClusterMatch = !hasSearch || clusterAssets.some(function(a) { return matchedIds.has(a.id); });
2198
+ var clusterDim = hasSearch && !isClusterMatch;
2199
+
2200
+ for (var ai = 0; ai < clusterAssets.length; ai++) {
2201
+ var asset = clusterAssets[ai];
2202
+ var wx = mHtp_x(asset.q, asset.r), wy = mHtp_y(asset.q, asset.r);
2203
+ var s = mW2s(wx, wy), cx = s.x, cy = s.y;
2204
+ if (cx + size < 0 || cx - size > mW || cy + size < 0 || cy - size > mH) continue;
2205
+
2206
+ var shade = ai % 3 === 0 ? 18 : ai % 3 === 1 ? 8 : 0;
2207
+ var fillColor = mShadeV(baseColor, shade);
2208
+ if (mShowQuality && asset.qualityScore !== null && asset.qualityScore !== undefined) {
2209
+ if (asset.qualityScore < 40) fillColor = '#ef4444';
2210
+ else if (asset.qualityScore < 70) fillColor = '#f97316';
2558
2211
  }
2559
- ctx.restore();
2560
-
2561
- // Quality dot
2562
- if (showQuality && asset.qualityScore!==null && asset.qualityScore!==undefined && size>8) {
2563
- const q = asset.qualityScore;
2564
- if (q < 70) {
2565
- ctx.beginPath();
2566
- ctx.arc(cx+size*0.4, cy-size*0.4, Math.max(3,size*0.14), 0, Math.PI*2);
2567
- ctx.fillStyle = q<40?'#ef4444':'#f97316';
2568
- ctx.fill();
2569
- }
2212
+
2213
+ var alpha = clusterDim ? 0.18 : 1;
2214
+ var isHov = asset.id === mHoveredId, isSel = asset.id === mSelectedId, isCF = asset.id === mConnectFirst;
2215
+
2216
+ mapCtx.save(); mapCtx.globalAlpha = alpha;
2217
+ mHexPath(cx, cy, size * 0.92);
2218
+ if (isDark && (isHov || isSel || isCF)) { mapCtx.shadowColor = fillColor; mapCtx.shadowBlur = isSel ? 16 : 8; }
2219
+ mapCtx.fillStyle = fillColor; mapCtx.fill();
2220
+ if (isSel || isCF) { mapCtx.strokeStyle = isCF ? '#f59e0b' : '#fff'; mapCtx.lineWidth = 2.5; mapCtx.stroke(); }
2221
+ else if (isHov) { mapCtx.strokeStyle = isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.2)'; mapCtx.lineWidth = 1.5; mapCtx.stroke(); }
2222
+ else { mapCtx.strokeStyle = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.4)'; mapCtx.lineWidth = 1; mapCtx.stroke(); }
2223
+ mapCtx.restore();
2224
+
2225
+ if (mShowQuality && asset.qualityScore !== null && asset.qualityScore !== undefined && size > 8 && asset.qualityScore < 70) {
2226
+ mapCtx.beginPath(); mapCtx.arc(cx + size * 0.4, cy - size * 0.4, Math.max(3, size * 0.14), 0, Math.PI * 2);
2227
+ mapCtx.fillStyle = asset.qualityScore < 40 ? '#ef4444' : '#f97316'; mapCtx.fill();
2570
2228
  }
2571
2229
 
2572
- // Asset labels (detail 4, or 3 at high zoom)
2573
- const showAssetLabel = showLabels && !clusterDim &&
2574
- ((detailLevel>=4)||(detailLevel===3 && scale>=0.8));
2575
- if (showAssetLabel && size>14) {
2576
- const label = asset.name.length>12 ? asset.name.substring(0,11)+'...' : asset.name;
2577
- ctx.save();
2578
- ctx.font = Math.max(8,Math.min(11,size*0.38))+'px -apple-system,sans-serif';
2579
- ctx.fillStyle = isDark ? 'rgba(255,255,255,0.85)' : 'rgba(255,255,255,0.9)';
2580
- ctx.textAlign='center';ctx.textBaseline='middle';
2581
- ctx.fillText(label, cx, cy);
2582
- ctx.restore();
2230
+ var showAssetLabel = mShowLabels && !clusterDim && (mShowAllLabels || (mDetailLevel >= 4) || (mDetailLevel === 3 && mScale >= 0.8));
2231
+ if (showAssetLabel && size > 6) {
2232
+ var label = asset.name.length > 12 ? asset.name.substring(0, 11) + '...' : asset.name;
2233
+ mapCtx.save();
2234
+ mapCtx.font = Math.max(8, Math.min(11, size * 0.38)) + 'px -apple-system,sans-serif';
2235
+ mapCtx.fillStyle = isDark ? 'rgba(255,255,255,0.85)' : 'rgba(255,255,255,0.9)';
2236
+ mapCtx.textAlign = 'center'; mapCtx.textBaseline = 'middle';
2237
+ mapCtx.fillText(label, cx, cy); mapCtx.restore();
2583
2238
  }
2584
2239
  }
2585
2240
  }
2586
2241
 
2587
- // Cluster labels (pill badges)
2588
- if (showLabels && detailLevel>=1) {
2589
- for (const cluster of MAP.clusters) {
2590
- if (cluster.assetIds.length===0) continue;
2591
- if (hasSearch && !cluster.assetIds.some(id=>matchedIds.has(id))) continue;
2592
- const s=w2s(cluster.centroid.x, cluster.centroid.y);
2593
- drawPill(s.x, s.y-size*1.2, cluster.label, cluster.color, 14);
2242
+ // Cluster labels
2243
+ if (mShowLabels && mDetailLevel >= 1) {
2244
+ for (var cli2 = 0; cli2 < MAP.clusters.length; cli2++) {
2245
+ var cl = MAP.clusters[cli2];
2246
+ if (cl.assetIds.length === 0) continue;
2247
+ if (hasSearch && !cl.assetIds.some(function(id) { return matchedIds.has(id); })) continue;
2248
+ var sc = mW2s(cl.centroid.x, cl.centroid.y);
2249
+ mDrawPill(sc.x, sc.y - size * 1.2, cl.label, cl.color, 14);
2594
2250
  }
2595
2251
  }
2596
2252
 
2597
- // Sub-domain labels (detail 2+)
2598
- if (showLabels && detailLevel>=2) {
2599
- const subGroups = new Map();
2600
- for (const a of MAP.assets) {
2601
- if (!a.subDomain) continue;
2602
- const key = a.domain+'|'+a.subDomain;
2253
+ // Sub-domain labels
2254
+ if (mShowLabels && mDetailLevel >= 2) {
2255
+ var subGroups = new Map();
2256
+ for (var si = 0; si < MAP.assets.length; si++) {
2257
+ var sa = MAP.assets[si];
2258
+ if (!sa.subDomain) continue;
2259
+ var key = sa.domain + '|' + sa.subDomain;
2603
2260
  if (!subGroups.has(key)) subGroups.set(key, []);
2604
- subGroups.get(key).push(a);
2605
- }
2606
- for (const [, group] of subGroups) {
2607
- let sx=0,sy=0;
2608
- for (const a of group) { sx+=htp_x(a.q,a.r); sy+=htp_y(a.q,a.r); }
2609
- const cx=sx/group.length, cy=sy/group.length;
2610
- const s = w2s(cx, cy);
2611
- drawPill(s.x, s.y+size*1.5, group[0].subDomain, '#64748b', 11);
2612
- }
2261
+ subGroups.get(key).push(sa);
2262
+ }
2263
+ subGroups.forEach(function(group) {
2264
+ var sx = 0, sy = 0;
2265
+ for (var gi = 0; gi < group.length; gi++) { sx += mHtp_x(group[gi].q, group[gi].r); sy += mHtp_y(group[gi].q, group[gi].r); }
2266
+ var cxs = sx / group.length, cys = sy / group.length;
2267
+ var spt = mW2s(cxs, cys);
2268
+ mDrawPill(spt.x, spt.y + size * 1.5, group[0].subDomain, '#64748b', 11);
2269
+ });
2613
2270
  }
2614
2271
  }
2615
2272
 
2616
- function drawPill(x, y, text, color, fontSize) {
2617
- if(!text) return;
2618
- ctx.save();
2619
- ctx.font = '600 '+fontSize+'px -apple-system,sans-serif';
2620
- const tw=ctx.measureText(text).width;
2621
- const ph=fontSize+8, pw=tw+20;
2622
- ctx.beginPath();
2623
- if (ctx.roundRect) ctx.roundRect(x-pw/2, y-ph/2, pw, ph, ph/2);
2624
- else { ctx.rect(x-pw/2, y-ph/2, pw, ph); }
2625
- ctx.fillStyle = isDark ? 'rgba(30,41,59,0.9)' : 'rgba(255,255,255,0.92)';
2626
- ctx.shadowColor='rgba(0,0,0,0.15)'; ctx.shadowBlur=6;
2627
- ctx.fill(); ctx.shadowBlur=0;
2628
- ctx.fillStyle = isDark ? '#e2e8f0' : '#0f172a';
2629
- ctx.textAlign='center'; ctx.textBaseline='middle';
2630
- ctx.fillText(text, x, y);
2631
- ctx.restore();
2632
- }
2633
-
2634
- // \u2500\u2500 Hit testing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2635
- function getAssetAt(sx, sy) {
2636
- const w=s2w(sx,sy);
2637
- for (const a of MAP.assets) {
2638
- const wx=htp_x(a.q,a.r), wy=htp_y(a.q,a.r);
2639
- const dx=Math.abs(w.x-wx), dy=Math.abs(w.y-wy);
2640
- if (dx>HEX_SIZE||dy>HEX_SIZE) continue;
2641
- if (dx*dx+dy*dy < HEX_SIZE*HEX_SIZE) return a;
2273
+ // \u2500\u2500 Map hit test \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2274
+ function mGetAssetAt(sx, sy) {
2275
+ var w = mS2w(sx, sy);
2276
+ for (var i = 0; i < MAP.assets.length; i++) {
2277
+ var a = MAP.assets[i], wx = mHtp_x(a.q, a.r), wy = mHtp_y(a.q, a.r);
2278
+ var dx = Math.abs(w.x - wx), dy = Math.abs(w.y - wy);
2279
+ if (dx > MAP_HEX || dy > MAP_HEX) continue;
2280
+ if (dx * dx + dy * dy < MAP_HEX * MAP_HEX) return a;
2642
2281
  }
2643
2282
  return null;
2644
2283
  }
2645
2284
 
2646
- // \u2500\u2500 Search \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2647
- function getSearchMatches() {
2648
- if(!searchQuery) return new Set();
2649
- const q=searchQuery.toLowerCase();
2650
- const m=new Set();
2651
- for(const a of MAP.assets){
2652
- if(a.name.toLowerCase().includes(q)||(a.domain&&a.domain.toLowerCase().includes(q))||
2653
- (a.subDomain&&a.subDomain.toLowerCase().includes(q))) m.add(a.id);
2654
- }
2655
- return m;
2656
- }
2657
-
2658
- // \u2500\u2500 Pan & 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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2659
- let dragging=false, lastMX=0, lastMY=0;
2660
-
2661
- wrap.addEventListener('mousedown', e=>{
2662
- if(e.button!==0)return;
2663
- dragging=true; lastMX=e.clientX; lastMY=e.clientY;
2664
- wrap.classList.add('dragging');
2285
+ // \u2500\u2500 Map pan / 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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2286
+ var mDragging = false, mLastMX = 0, mLastMY = 0;
2287
+ mapWrap.addEventListener('mousedown', function(e) {
2288
+ if (e.button !== 0) return;
2289
+ mDragging = true; mLastMX = e.clientX; mLastMY = e.clientY;
2290
+ mapWrap.classList.add('dragging');
2665
2291
  });
2666
- window.addEventListener('mouseup', ()=>{dragging=false;wrap.classList.remove('dragging');});
2667
- window.addEventListener('mousemove', e=>{
2668
- if(dragging){
2669
- vx+=e.clientX-lastMX; vy+=e.clientY-lastMY;
2670
- lastMX=e.clientX; lastMY=e.clientY; draw(); return;
2671
- }
2672
- const rect=wrap.getBoundingClientRect();
2673
- const sx=e.clientX-rect.left, sy=e.clientY-rect.top;
2674
- const asset=getAssetAt(sx,sy);
2675
- const newId=asset?asset.id:null;
2676
- if(newId!==hoveredAssetId){hoveredAssetId=newId;draw();}
2677
- const tt=document.getElementById('tooltip');
2678
- if(asset){
2679
- document.getElementById('tt-name').textContent=asset.name;
2680
- document.getElementById('tt-domain').textContent=asset.domain+(asset.subDomain?' > '+asset.subDomain:'');
2681
- document.getElementById('tt-quality').textContent=asset.qualityScore!==null?'Quality: '+asset.qualityScore+'/100':'';
2682
- tt.style.display='block';tt.style.left=(e.clientX+12)+'px';tt.style.top=(e.clientY-8)+'px';
2683
- } else { tt.style.display='none'; }
2292
+ window.addEventListener('mouseup', function() { mDragging = false; mapWrap.classList.remove('dragging'); });
2293
+ window.addEventListener('mousemove', function(e) {
2294
+ if (currentTab !== 'map') return;
2295
+ if (mDragging) {
2296
+ mvx += e.clientX - mLastMX; mvy += e.clientY - mLastMY;
2297
+ mLastMX = e.clientX; mLastMY = e.clientY; drawMap(); return;
2298
+ }
2299
+ var rect = mapWrap.getBoundingClientRect();
2300
+ var sx = e.clientX - rect.left, sy = e.clientY - rect.top;
2301
+ var asset = mGetAssetAt(sx, sy);
2302
+ var newId = asset ? asset.id : null;
2303
+ if (newId !== mHoveredId) { mHoveredId = newId; drawMap(); }
2304
+ var tt = document.getElementById('map-tooltip');
2305
+ if (asset) {
2306
+ document.getElementById('mtt-name').textContent = asset.name;
2307
+ document.getElementById('mtt-domain').textContent = asset.domain + (asset.subDomain ? ' > ' + asset.subDomain : '');
2308
+ document.getElementById('mtt-quality').textContent = asset.qualityScore !== null ? 'Quality: ' + asset.qualityScore + '/100' : '';
2309
+ tt.style.display = 'block'; tt.style.left = (e.clientX + 12) + 'px'; tt.style.top = (e.clientY - 8) + 'px';
2310
+ } else { tt.style.display = 'none'; }
2684
2311
  });
2685
2312
 
2686
- wrap.addEventListener('click', e=>{
2687
- const rect=wrap.getBoundingClientRect();
2688
- const sx=e.clientX-rect.left, sy=e.clientY-rect.top;
2689
- const asset=getAssetAt(sx,sy);
2690
- if(connectMode){
2691
- if(!asset) return;
2692
- if(!connectFirst){connectFirst=asset.id;draw();}
2693
- else if(connectFirst!==asset.id){
2694
- localConnections.push({id:crypto.randomUUID(),sourceAssetId:connectFirst,targetAssetId:asset.id,type:'connection'});
2695
- connectFirst=null;draw();
2313
+ mapWrap.addEventListener('click', function(e) {
2314
+ var rect = mapWrap.getBoundingClientRect();
2315
+ var sx = e.clientX - rect.left, sy = e.clientY - rect.top;
2316
+ var asset = mGetAssetAt(sx, sy);
2317
+ if (mConnectMode) {
2318
+ if (!asset) return;
2319
+ if (!mConnectFirst) { mConnectFirst = asset.id; drawMap(); }
2320
+ else if (mConnectFirst !== asset.id) {
2321
+ mLocalConns.push({ id: crypto.randomUUID(), sourceAssetId: mConnectFirst, targetAssetId: asset.id, type: 'connection' });
2322
+ mConnectFirst = null; drawMap();
2696
2323
  }
2697
2324
  return;
2698
2325
  }
2699
- if(asset){selectedAssetId=asset.id;showDetailPanel(asset);}
2700
- else{selectedAssetId=null;document.getElementById('detail-panel').classList.remove('open');}
2701
- draw();
2326
+ if (asset) { mSelectedId = asset.id; mShowDetail(asset); }
2327
+ else { mSelectedId = null; document.getElementById('map-detail').classList.remove('open'); }
2328
+ drawMap();
2702
2329
  });
2703
2330
 
2704
- // Touch
2705
- let lastTouches=[];
2706
- wrap.addEventListener('touchstart',e=>{lastTouches=[...e.touches];},{passive:true});
2707
- wrap.addEventListener('touchmove',e=>{
2708
- if(e.touches.length===1){
2709
- vx+=e.touches[0].clientX-lastTouches[0].clientX;
2710
- vy+=e.touches[0].clientY-lastTouches[0].clientY;draw();
2711
- } else if(e.touches.length===2){
2712
- const d0=Math.hypot(lastTouches[0].clientX-lastTouches[1].clientX,lastTouches[0].clientY-lastTouches[1].clientY);
2713
- const d1=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY);
2714
- const mx=(e.touches[0].clientX+e.touches[1].clientX)/2;
2715
- const my=(e.touches[0].clientY+e.touches[1].clientY)/2;
2716
- applyZoom(d1/d0,mx,my);
2717
- }
2718
- lastTouches=[...e.touches];
2719
- },{passive:true});
2720
-
2721
- wrap.addEventListener('wheel',e=>{
2331
+ mapWrap.addEventListener('wheel', function(e) {
2722
2332
  e.preventDefault();
2723
- const rect=wrap.getBoundingClientRect();
2724
- applyZoom(e.deltaY<0?1.12:1/1.12,e.clientX-rect.left,e.clientY-rect.top);
2725
- },{passive:false});
2726
-
2727
- function applyZoom(factor,sx,sy){
2728
- const ns=Math.max(0.05,Math.min(8,scale*factor));
2729
- const wx=(sx-vx)/scale,wy=(sy-vy)/scale;
2730
- scale=ns;vx=sx-wx*scale;vy=sy-wy*scale;
2731
- document.getElementById('zoom-pct').textContent=Math.round(scale*100)+'%';draw();
2732
- }
2733
- document.getElementById('zoom-in').addEventListener('click',()=>applyZoom(1.25,W/2,H/2));
2734
- document.getElementById('zoom-out').addEventListener('click',()=>applyZoom(1/1.25,W/2,H/2));
2735
-
2736
- // Keyboard
2737
- wrap.addEventListener('keydown',e=>{
2738
- const step=40;
2739
- if(e.key==='ArrowLeft'){vx+=step;draw();}
2740
- else if(e.key==='ArrowRight'){vx-=step;draw();}
2741
- else if(e.key==='ArrowUp'){vy+=step;draw();}
2742
- else if(e.key==='ArrowDown'){vy-=step;draw();}
2743
- else if(e.key==='+'||e.key==='=')applyZoom(1.2,W/2,H/2);
2744
- else if(e.key==='-')applyZoom(1/1.2,W/2,H/2);
2745
- else if(e.key==='Escape'){
2746
- selectedAssetId=null;document.getElementById('detail-panel').classList.remove('open');
2747
- if(connectMode)toggleConnect();draw();
2748
- }
2333
+ var rect = mapWrap.getBoundingClientRect();
2334
+ mApplyZoom(e.deltaY < 0 ? 1.12 : 1 / 1.12, e.clientX - rect.left, e.clientY - rect.top);
2335
+ }, { passive: false });
2336
+
2337
+ function mApplyZoom(factor, sx, sy) {
2338
+ var ns = Math.max(0.05, Math.min(8, mScale * factor));
2339
+ var wx = (sx - mvx) / mScale, wy = (sy - mvy) / mScale;
2340
+ mScale = ns; mvx = sx - wx * mScale; mvy = sy - wy * mScale;
2341
+ document.getElementById('map-zoom-pct').textContent = Math.round(mScale * 100) + '%';
2342
+ drawMap();
2343
+ }
2344
+
2345
+ document.getElementById('mz-in').addEventListener('click', function() { mApplyZoom(1.25, mW / 2, mH / 2); });
2346
+ document.getElementById('mz-out').addEventListener('click', function() { mApplyZoom(1 / 1.25, mW / 2, mH / 2); });
2347
+
2348
+ // \u2500\u2500 Map detail 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
2349
+ function mEsc(s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
2350
+ function mRenderQ(s) {
2351
+ var c = s >= 70 ? '#22c55e' : s >= 40 ? '#f97316' : '#ef4444';
2352
+ return s + '/100 <div class="quality-bar"><div class="quality-fill" style="width:' + s + '%;background:' + c + '"></div></div>';
2353
+ }
2354
+ function mShowDetail(asset) {
2355
+ document.getElementById('md-name').textContent = asset.name;
2356
+ var body = document.getElementById('md-body');
2357
+ var rows = [['Domain', asset.domain], ['Sub-domain', asset.subDomain],
2358
+ ['Quality', asset.qualityScore !== null ? mRenderQ(asset.qualityScore) : null]
2359
+ ].concat(Object.entries(asset.metadata || {}).slice(0, 8).map(function(kv) { return [kv[0], String(kv[1])]; }))
2360
+ .filter(function(r) { return r[1] !== null && r[1] !== undefined && r[1] !== ''; });
2361
+ body.innerHTML = rows.map(function(r) {
2362
+ return '<div class="meta-row"><div class="meta-label">' + mEsc(String(r[0])) + '</div><div class="meta-value">' + r[1] + '</div></div>';
2363
+ }).join('');
2364
+ var related = mLocalConns.filter(function(cn) { return cn.sourceAssetId === asset.id || cn.targetAssetId === asset.id; });
2365
+ if (related.length > 0) {
2366
+ body.innerHTML += '<div class="meta-row"><div class="meta-label">Connections (' + related.length + ')</div><div>' +
2367
+ related.map(function(cn) {
2368
+ var oid = cn.sourceAssetId === asset.id ? cn.targetAssetId : cn.sourceAssetId;
2369
+ var o = mapAssetIndex.get(oid);
2370
+ return '<div class="meta-value" style="margin-top:4px;font-size:12px">' + (o ? mEsc(o.name) : oid) + '</div>';
2371
+ }).join('') + '</div></div>';
2372
+ }
2373
+ document.getElementById('map-detail').classList.add('open');
2374
+ }
2375
+ document.getElementById('md-close').addEventListener('click', function() {
2376
+ document.getElementById('map-detail').classList.remove('open'); mSelectedId = null; drawMap();
2749
2377
  });
2750
2378
 
2751
- // \u2500\u2500 Detail 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\u2500\u2500
2752
- function showDetailPanel(asset) {
2753
- document.getElementById('dp-name').textContent=asset.name;
2754
- const body=document.getElementById('dp-body');
2755
- const rows=[['Domain',asset.domain],['Sub-domain',asset.subDomain],
2756
- ['Quality Score',asset.qualityScore!==null?renderQuality(asset.qualityScore):null],
2757
- ...Object.entries(asset.metadata||{}).slice(0,8).map(([k,v])=>[k,String(v)])
2758
- ].filter(([,v])=>v!==null&&v!==undefined&&v!=='');
2759
- body.innerHTML=rows.map(([l,v])=>'<div class="meta-row"><div class="meta-label">'+esc(String(l))+'</div><div class="meta-value">'+v+'</div></div>').join('');
2760
- const related=localConnections.filter(c=>c.sourceAssetId===asset.id||c.targetAssetId===asset.id);
2761
- if(related.length>0){
2762
- body.innerHTML+='<div class="meta-row"><div class="meta-label">Connections ('+related.length+')</div><div>'+
2763
- related.map(c=>{const oid=c.sourceAssetId===asset.id?c.targetAssetId:c.sourceAssetId;
2764
- const o=assetIndex.get(oid);return '<div class="meta-value" style="margin-top:4px;font-size:12px">'+(o?esc(o.name):oid)+'</div>';}).join('')+'</div></div>';
2765
- }
2766
- document.getElementById('detail-panel').classList.add('open');
2767
- }
2768
- function renderQuality(s){
2769
- const c=s>=70?'#22c55e':s>=40?'#f97316':'#ef4444';
2770
- return s+'/100 <div class="quality-bar"><div class="quality-fill" style="width:'+s+'%;background:'+c+'"></div></div>';
2771
- }
2772
- function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
2773
- document.getElementById('dp-close').addEventListener('click',()=>{
2774
- document.getElementById('detail-panel').classList.remove('open');selectedAssetId=null;draw();
2379
+ // \u2500\u2500 Map toolbar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2380
+ document.getElementById('btn-labels').addEventListener('click', function() {
2381
+ mShowLabels = !mShowLabels; this.classList.toggle('active', mShowLabels); drawMap();
2775
2382
  });
2776
-
2777
- // \u2500\u2500 Toolbar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2778
- [1,2,3,4].forEach(n=>{
2779
- document.getElementById('dl-'+n).addEventListener('click',()=>{
2780
- detailLevel=n;document.querySelectorAll('.detail-btn').forEach(b=>b.classList.remove('active'));
2781
- document.getElementById('dl-'+n).classList.add('active');draw();
2782
- });
2383
+ document.getElementById('btn-all-labels').addEventListener('click', function() {
2384
+ mShowAllLabels = !mShowAllLabels; this.classList.toggle('active', mShowAllLabels);
2385
+ if (mShowAllLabels && !mShowLabels) { mShowLabels = true; document.getElementById('btn-labels').classList.add('active'); }
2386
+ drawMap();
2783
2387
  });
2784
- document.getElementById('btn-labels').addEventListener('click',()=>{
2785
- showLabels=!showLabels;document.getElementById('btn-labels').classList.toggle('active',showLabels);draw();
2388
+ document.getElementById('btn-quality').addEventListener('click', function() {
2389
+ mShowQuality = !mShowQuality; this.classList.toggle('active', mShowQuality); drawMap();
2786
2390
  });
2787
- document.getElementById('btn-quality').addEventListener('click',()=>{
2788
- showQuality=!showQuality;document.getElementById('btn-quality').classList.toggle('active',showQuality);draw();
2391
+ document.getElementById('btn-connect').addEventListener('click', function() {
2392
+ mConnectMode = !mConnectMode; mConnectFirst = null;
2393
+ this.classList.toggle('active', mConnectMode);
2394
+ mapWrap.classList.toggle('connecting', mConnectMode);
2395
+ document.getElementById('map-connect-hint').style.display = mConnectMode ? 'block' : 'none'; drawMap();
2789
2396
  });
2790
- function toggleConnect(){
2791
- connectMode=!connectMode;connectFirst=null;
2792
- document.getElementById('connect-btn').classList.toggle('active',connectMode);
2793
- wrap.classList.toggle('connecting',connectMode);
2794
- document.getElementById('connect-hint').style.display=connectMode?'block':'none';draw();
2795
- }
2796
- document.getElementById('connect-btn').addEventListener('click',toggleConnect);
2797
- document.getElementById('theme-btn').addEventListener('click',()=>{
2798
- isDark=!isDark;
2799
- document.body.classList.toggle('dark',isDark);document.body.classList.toggle('light',!isDark);
2800
- document.getElementById('theme-btn').innerHTML=isDark?'&#9788;':'&#9790;';draw();
2397
+
2398
+ // Map keyboard
2399
+ mapWrap.addEventListener('keydown', function(e) {
2400
+ if (e.key === 'ArrowLeft') { mvx += 40; drawMap(); }
2401
+ else if (e.key === 'ArrowRight') { mvx -= 40; drawMap(); }
2402
+ else if (e.key === 'ArrowUp') { mvy += 40; drawMap(); }
2403
+ else if (e.key === 'ArrowDown') { mvy -= 40; drawMap(); }
2404
+ else if (e.key === '+' || e.key === '=') mApplyZoom(1.2, mW / 2, mH / 2);
2405
+ else if (e.key === '-') mApplyZoom(1 / 1.2, mW / 2, mH / 2);
2406
+ else if (e.key === 'Escape') {
2407
+ mSelectedId = null; document.getElementById('map-detail').classList.remove('open');
2408
+ if (mConnectMode) { mConnectMode = false; mConnectFirst = null; mapWrap.classList.remove('connecting'); document.getElementById('map-connect-hint').style.display = 'none'; document.getElementById('btn-connect').classList.remove('active'); }
2409
+ drawMap();
2410
+ }
2801
2411
  });
2802
- document.getElementById('search-input').addEventListener('input',e=>{searchQuery=e.target.value.trim();draw();});
2803
2412
 
2804
- // \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\u2500\u2500\u2500\u2500
2805
- resize(); fitToView();
2806
- document.getElementById('zoom-pct').textContent=Math.round(scale*100)+'%';
2807
- draw();
2808
- })();
2413
+ // Map init
2414
+ resizeMap(); mapFitToView();
2415
+ document.getElementById('map-zoom-pct').textContent = Math.round(mScale * 100) + '%';
2416
+ drawMap();
2417
+
2418
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2419
+ // TOPOLOGY VIEW (lazy init)
2420
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2421
+ var TOPO = ${graphData};
2422
+
2423
+ var TYPE_COLORS = {
2424
+ host:'#4a9eff',database_server:'#ff6b6b',database:'#ff8c42',
2425
+ web_service:'#6bcb77',api_endpoint:'#4d96ff',cache_server:'#ffd93d',
2426
+ message_broker:'#c77dff',queue:'#e0aaff',topic:'#9d4edd',
2427
+ container:'#48cae4',pod:'#00b4d8',k8s_cluster:'#0077b6',
2428
+ config_file:'#adb5bd',saas_tool:'#c084fc',table:'#f97316',unknown:'#6c757d'
2429
+ };
2430
+ var LAYER_COLORS = { saas:'#c084fc',web:'#6bcb77',data:'#ff6b6b',messaging:'#c77dff',infra:'#4a9eff',config:'#adb5bd',other:'#6c757d' };
2431
+ var LAYER_NAMES = { saas:'SaaS Tools',web:'Web / API',data:'Data Layer',messaging:'Messaging',infra:'Infrastructure',config:'Config',other:'Other' };
2432
+
2433
+ var topoSelectedId = null;
2434
+
2435
+ function setTopoSearch(q) {
2436
+ var el = document.getElementById('topo-search');
2437
+ if (el) { el.value = q; buildTopoList(q); }
2438
+ }
2439
+
2440
+ function buildTopoList(filter) {
2441
+ var listEl = document.getElementById('topo-list');
2442
+ var q = (filter || '').toLowerCase();
2443
+ listEl.innerHTML = '';
2444
+ var sorted = TOPO.nodes.slice().sort(function(a, b) { return a.name.localeCompare(b.name); });
2445
+ for (var i = 0; i < sorted.length; i++) {
2446
+ var d = sorted[i];
2447
+ if (q && !d.name.toLowerCase().includes(q) && !d.type.includes(q) && !d.id.toLowerCase().includes(q)) continue;
2448
+ var item = document.createElement('div');
2449
+ item.className = 'topo-item' + (d.id === topoSelectedId ? ' active' : '');
2450
+ item.dataset.id = d.id;
2451
+ var color = TYPE_COLORS[d.type] || '#aaa';
2452
+ item.innerHTML = '<span class="topo-dot" style="background:' + color + '"></span>' +
2453
+ '<span class="topo-name" title="' + d.id + '">' + d.name + '</span>' +
2454
+ '<span class="topo-type">' + d.type.replace(/_/g, ' ') + '</span>';
2455
+ (function(dd) { item.onclick = function() { selectTopoNode(dd); focusTopoNode(dd); }; })(d);
2456
+ listEl.appendChild(item);
2457
+ }
2458
+ }
2459
+
2460
+ document.getElementById('topo-search').addEventListener('input', function(e) { buildTopoList(e.target.value); });
2461
+
2462
+ var topoSidebar = document.getElementById('topo-sidebar');
2463
+
2464
+ function selectTopoNode(d) {
2465
+ topoSelectedId = d.id;
2466
+ buildTopoList(document.getElementById('topo-search').value);
2467
+ showTopoNode(d);
2468
+ if (typeof d3 !== 'undefined') d3.selectAll('.node-hex').classed('selected', function(nd) { return nd.id === d.id; });
2469
+ }
2470
+
2471
+ function showTopoNode(d) {
2472
+ var c = TYPE_COLORS[d.type] || '#aaa';
2473
+ var confPct = Math.round(d.confidence * 100);
2474
+ var tags = (d.tags || []).map(function(t) { return '<span class="tag">' + t + '</span>'; }).join('');
2475
+ var metaRows = Object.entries(d.metadata || {})
2476
+ .filter(function(kv) { return kv[1] !== null && kv[1] !== undefined && String(kv[1]).length > 0; })
2477
+ .map(function(kv) { return '<tr><td>' + kv[0] + '</td><td>' + JSON.stringify(kv[1]) + '</td></tr>'; }).join('');
2478
+ var related = TOPO.links.filter(function(l) {
2479
+ return (l.source.id || l.source) === d.id || (l.target.id || l.target) === d.id;
2480
+ });
2481
+ var edgeItems = related.map(function(l) {
2482
+ var isOut = (l.source.id || l.source) === d.id;
2483
+ var other = isOut ? (l.target.id || l.target) : (l.source.id || l.source);
2484
+ return '<div class="edge-item">' + (isOut ? '\\u2192' : '\\u2190') + ' <span>' + other + '</span> <small>[' + l.relationship + ']</small></div>';
2485
+ }).join('');
2486
+
2487
+ topoSidebar.innerHTML =
2488
+ '<h2>' + d.name + '</h2>' +
2489
+ '<table class="meta-table">' +
2490
+ '<tr><td>ID</td><td style="font-size:10px;word-break:break-all">' + d.id + '</td></tr>' +
2491
+ '<tr><td>Type</td><td><span style="color:' + c + '">' + d.type + '</span></td></tr>' +
2492
+ '<tr><td>Layer</td><td>' + d.layer + '</td></tr>' +
2493
+ '<tr><td>Confidence</td><td>' + confPct + '% <div class="conf-bar"><div class="conf-fill" style="width:' + confPct + '%;background:' + c + '"></div></div></td></tr>' +
2494
+ '<tr><td>Via</td><td>' + (d.discoveredVia || '\\u2014') + '</td></tr>' +
2495
+ '<tr><td>Timestamp</td><td>' + (d.discoveredAt ? d.discoveredAt.substring(0, 19).replace('T', ' ') : '\\u2014') + '</td></tr>' +
2496
+ (tags ? '<tr><td>Tags</td><td>' + tags + '</td></tr>' : '') +
2497
+ metaRows + '</table>' +
2498
+ (related.length > 0 ? '<div class="edges-list"><strong>Connections (' + related.length + '):</strong>' + edgeItems + '</div>' : '') +
2499
+ '<div style="margin-top:14px"><button class="export-btn" style="width:100%" onclick="deleteTopoNode(\\'' + d.id.replace(/'/g, "\\\\'") + '\\')">Delete node</button></div>';
2500
+ }
2501
+
2502
+ function deleteTopoNode(id) {
2503
+ var idx = TOPO.nodes.findIndex(function(n) { return n.id === id; });
2504
+ if (idx === -1) return;
2505
+ TOPO.nodes.splice(idx, 1);
2506
+ TOPO.links = TOPO.links.filter(function(l) {
2507
+ return (l.source.id || l.source) !== id && (l.target.id || l.target) !== id;
2508
+ });
2509
+ topoSelectedId = null;
2510
+ topoSidebar.innerHTML = '<h2>Infrastructure Map</h2><p class="hint">Node deleted.</p>';
2511
+ if (typeof rebuildTopoGraph === 'function') rebuildTopoGraph();
2512
+ buildTopoList(document.getElementById('topo-search').value);
2513
+ }
2514
+
2515
+ function initTopology() {
2516
+ if (typeof d3 === 'undefined') return;
2517
+
2518
+ var svgEl = d3.select('#topo-graph svg');
2519
+ var graphDiv = document.getElementById('topo-graph');
2520
+ var gW = function() { return graphDiv.clientWidth; };
2521
+ var gH = function() { return graphDiv.clientHeight; };
2522
+ var g = svgEl.append('g');
2523
+
2524
+ svgEl.append('defs').append('marker')
2525
+ .attr('id', 'arrow').attr('viewBox', '0 0 10 6')
2526
+ .attr('refX', 10).attr('refY', 3)
2527
+ .attr('markerWidth', 8).attr('markerHeight', 6)
2528
+ .attr('orient', 'auto')
2529
+ .append('path').attr('d', 'M0,0 L10,3 L0,6 Z').attr('fill', '#555');
2530
+
2531
+ var currentZoom = 1;
2532
+ var zoomBehavior = d3.zoom().scaleExtent([0.08, 6]).on('zoom', function(e) {
2533
+ g.attr('transform', e.transform); currentZoom = e.transform.k; updateTopoLOD(currentZoom);
2534
+ });
2535
+ svgEl.call(zoomBehavior);
2536
+
2537
+ // Layer filters
2538
+ var layers = Array.from(new Set(TOPO.nodes.map(function(d) { return d.layer; })));
2539
+ var layerVisible = {};
2540
+ layers.forEach(function(l) { layerVisible[l] = true; });
2541
+
2542
+ var toolbarEl = document.getElementById('topo-toolbar');
2543
+ layers.forEach(function(layer) {
2544
+ var btn = document.createElement('button');
2545
+ btn.className = 'filter-btn';
2546
+ btn.innerHTML = '<span class="filter-dot" style="background:' + (LAYER_COLORS[layer] || '#666') + '"></span>' + (LAYER_NAMES[layer] || layer);
2547
+ btn.onclick = function() { layerVisible[layer] = !layerVisible[layer]; btn.classList.toggle('off', !layerVisible[layer]); updateTopoVisibility(); };
2548
+ toolbarEl.appendChild(btn);
2549
+ });
2550
+
2551
+ // JGF export button
2552
+ var jgfBtn = document.createElement('button');
2553
+ jgfBtn.className = 'export-btn'; jgfBtn.textContent = '\\u2193 JGF'; jgfBtn.title = 'Export JSON Graph Format';
2554
+ jgfBtn.onclick = function() {
2555
+ var jgf = { graph: { directed: true, type: 'cartography', label: 'Infrastructure Map',
2556
+ metadata: { exportedAt: new Date().toISOString() },
2557
+ nodes: Object.fromEntries(TOPO.nodes.map(function(n) { return [n.id, { label: n.name, metadata: { type: n.type, layer: n.layer, confidence: n.confidence, discoveredVia: n.discoveredVia, discoveredAt: n.discoveredAt, tags: n.tags } }]; })),
2558
+ edges: TOPO.links.map(function(l) { return { source: l.source.id || l.source, target: l.target.id || l.target, relation: l.relationship, metadata: { confidence: l.confidence, evidence: l.evidence } }; })
2559
+ }};
2560
+ var blob = new Blob([JSON.stringify(jgf, null, 2)], { type: 'application/json' });
2561
+ var url = URL.createObjectURL(blob);
2562
+ var a = document.createElement('a'); a.href = url; a.download = 'cartography-graph.jgf.json'; a.click();
2563
+ URL.revokeObjectURL(url);
2564
+ };
2565
+ toolbarEl.appendChild(jgfBtn);
2566
+
2567
+ // Hex helpers
2568
+ var T_HEX = { saas_tool: 16, host: 18, database_server: 18, k8s_cluster: 20, default: 14 };
2569
+ function tHexSize(d) { return T_HEX[d.type] || T_HEX.default; }
2570
+ function tHexPath(size) {
2571
+ var pts = [];
2572
+ for (var i = 0; i < 6; i++) {
2573
+ var angle = (Math.PI / 3) * i - Math.PI / 6;
2574
+ pts.push([size * Math.cos(angle), size * Math.sin(angle)]);
2575
+ }
2576
+ return 'M' + pts.map(function(p) { return p.join(','); }).join('L') + 'Z';
2577
+ }
2578
+
2579
+ // Cluster force
2580
+ function clusterForce(alpha) {
2581
+ var centroids = {}, counts = {};
2582
+ TOPO.nodes.forEach(function(d) {
2583
+ if (!centroids[d.layer]) { centroids[d.layer] = { x: 0, y: 0 }; counts[d.layer] = 0; }
2584
+ centroids[d.layer].x += d.x || 0; centroids[d.layer].y += d.y || 0; counts[d.layer]++;
2585
+ });
2586
+ for (var l in centroids) { centroids[l].x /= counts[l]; centroids[l].y /= counts[l]; }
2587
+ var strength = alpha * 0.15;
2588
+ TOPO.nodes.forEach(function(d) {
2589
+ var cn = centroids[d.layer];
2590
+ if (cn) { d.vx += (cn.x - d.x) * strength; d.vy += (cn.y - d.y) * strength; }
2591
+ });
2592
+ }
2593
+
2594
+ // Hulls
2595
+ var hullGroup = g.append('g').attr('class', 'hulls');
2596
+ var hullPaths = {}, hullLabels = {};
2597
+ layers.forEach(function(layer) {
2598
+ hullPaths[layer] = hullGroup.append('path').attr('class', 'hull')
2599
+ .attr('fill', LAYER_COLORS[layer] || '#666').attr('stroke', LAYER_COLORS[layer] || '#666');
2600
+ hullLabels[layer] = hullGroup.append('text').attr('class', 'hull-label')
2601
+ .attr('fill', LAYER_COLORS[layer] || '#666').text(LAYER_NAMES[layer] || layer);
2602
+ });
2603
+
2604
+ function updateHulls() {
2605
+ layers.forEach(function(layer) {
2606
+ if (!layerVisible[layer]) { hullPaths[layer].attr('d', null); hullLabels[layer].attr('x', -9999); return; }
2607
+ var pts = TOPO.nodes.filter(function(d) { return d.layer === layer && layerVisible[d.layer]; }).map(function(d) { return [d.x, d.y]; });
2608
+ if (pts.length < 3) {
2609
+ hullPaths[layer].attr('d', null);
2610
+ if (pts.length > 0) hullLabels[layer].attr('x', pts[0][0]).attr('y', pts[0][1] - 30);
2611
+ else hullLabels[layer].attr('x', -9999);
2612
+ return;
2613
+ }
2614
+ var hull = d3.polygonHull(pts);
2615
+ if (!hull) { hullPaths[layer].attr('d', null); return; }
2616
+ var cx = d3.mean(hull, function(p) { return p[0]; });
2617
+ var cy = d3.mean(hull, function(p) { return p[1]; });
2618
+ var padded = hull.map(function(p) {
2619
+ var dx = p[0] - cx, dy = p[1] - cy;
2620
+ var len = Math.sqrt(dx * dx + dy * dy) || 1;
2621
+ return [p[0] + dx / len * 40, p[1] + dy / len * 40];
2622
+ });
2623
+ hullPaths[layer].attr('d', 'M' + padded.join('L') + 'Z');
2624
+ hullLabels[layer].attr('x', cx).attr('y', cy - d3.max(hull, function(p) { return Math.abs(p[1] - cy); }) - 30);
2625
+ });
2626
+ }
2627
+
2628
+ // Graph
2629
+ var linkSel, linkLabelSel, nodeSel, nodeLabelSel, sim;
2630
+ var linkGroup = g.append('g');
2631
+ var nodeGroup = g.append('g');
2632
+
2633
+ function focusTopoNode(d) {
2634
+ if (!d.x || !d.y) return;
2635
+ var w = gW(), h = gH();
2636
+ svgEl.transition().duration(500).call(
2637
+ zoomBehavior.transform,
2638
+ d3.zoomIdentity.translate(w / 2, h / 2).scale(Math.min(3, currentZoom < 1 ? 1.5 : currentZoom)).translate(-d.x, -d.y)
2639
+ );
2640
+ }
2641
+ window.focusTopoNode = focusTopoNode;
2642
+
2643
+ function rebuildTopoGraph() {
2644
+ if (sim) sim.stop();
2645
+
2646
+ linkSel = linkGroup.selectAll('line').data(TOPO.links, function(d) { return (d.source.id || d.source) + '>' + (d.target.id || d.target); });
2647
+ linkSel.exit().remove();
2648
+ var linkEnter = linkSel.enter().append('line').attr('class', 'link');
2649
+ linkSel = linkEnter.merge(linkSel)
2650
+ .attr('stroke', function(d) { return d.confidence < 0.6 ? '#2a2e35' : '#3d434b'; })
2651
+ .attr('stroke-dasharray', function(d) { return d.confidence < 0.6 ? '4 3' : null; })
2652
+ .attr('stroke-width', function(d) { return d.confidence < 0.6 ? 0.8 : 1.2; })
2653
+ .attr('marker-end', 'url(#arrow)');
2654
+ linkSel.select('title').remove();
2655
+ linkSel.append('title').text(function(d) { return d.relationship + ' (' + Math.round(d.confidence * 100) + '%)\\n' + (d.evidence || ''); });
2656
+
2657
+ linkLabelSel = linkGroup.selectAll('text').data(TOPO.links, function(d) { return (d.source.id || d.source) + '>' + (d.target.id || d.target); });
2658
+ linkLabelSel.exit().remove();
2659
+ linkLabelSel = linkLabelSel.enter().append('text').attr('class', 'link-label').merge(linkLabelSel).text(function(d) { return d.relationship; });
2660
+
2661
+ nodeSel = nodeGroup.selectAll('g').data(TOPO.nodes, function(d) { return d.id; });
2662
+ nodeSel.exit().remove();
2663
+ var nodeEnter = nodeSel.enter().append('g')
2664
+ .call(d3.drag()
2665
+ .on('start', function(e, d) { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
2666
+ .on('drag', function(e, d) { d.fx = e.x; d.fy = e.y; })
2667
+ .on('end', function(e, d) { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; })
2668
+ )
2669
+ .on('click', function(e, d) { e.stopPropagation(); selectTopoNode(d); });
2670
+ nodeEnter.append('path').attr('class', 'node-hex');
2671
+ nodeEnter.append('title');
2672
+ nodeEnter.append('text').attr('class', 'node-label').attr('text-anchor', 'middle');
2673
+
2674
+ nodeSel = nodeEnter.merge(nodeSel);
2675
+ nodeSel.select('.node-hex')
2676
+ .attr('d', function(d) { return tHexPath(tHexSize(d)); })
2677
+ .attr('fill', function(d) { return TYPE_COLORS[d.type] || '#aaa'; })
2678
+ .attr('stroke', function(d) { var c = d3.color(TYPE_COLORS[d.type] || '#aaa'); return c ? c.brighter(0.8).formatHex() : '#ccc'; })
2679
+ .attr('fill-opacity', function(d) { return 0.6 + d.confidence * 0.4; })
2680
+ .classed('selected', function(d) { return d.id === topoSelectedId; });
2681
+ nodeSel.select('title').text(function(d) { return d.name + ' (' + d.type + ')\\nconf: ' + Math.round(d.confidence * 100) + '%'; });
2682
+ nodeLabelSel = nodeSel.select('.node-label')
2683
+ .attr('dy', function(d) { return tHexSize(d) + 13; })
2684
+ .text(function(d) { return d.name.length > 20 ? d.name.substring(0, 18) + '\\u2026' : d.name; });
2685
+
2686
+ sim = d3.forceSimulation(TOPO.nodes)
2687
+ .force('link', d3.forceLink(TOPO.links).id(function(d) { return d.id; }).distance(function(d) { return d.relationship === 'contains' ? 50 : 100; }).strength(0.4))
2688
+ .force('charge', d3.forceManyBody().strength(-280))
2689
+ .force('center', d3.forceCenter(gW() / 2, gH() / 2))
2690
+ .force('collision', d3.forceCollide().radius(function(d) { return tHexSize(d) + 10; }))
2691
+ .force('cluster', clusterForce)
2692
+ .on('tick', function() {
2693
+ updateHulls();
2694
+ linkSel.attr('x1', function(d) { return d.source.x; }).attr('y1', function(d) { return d.source.y; })
2695
+ .attr('x2', function(d) { return d.target.x; }).attr('y2', function(d) { return d.target.y; });
2696
+ linkLabelSel.attr('x', function(d) { return (d.source.x + d.target.x) / 2; })
2697
+ .attr('y', function(d) { return (d.source.y + d.target.y) / 2 - 4; });
2698
+ nodeSel.attr('transform', function(d) { return 'translate(' + d.x + ',' + d.y + ')'; });
2699
+ });
2700
+ }
2701
+ window.rebuildTopoGraph = rebuildTopoGraph;
2702
+
2703
+ function updateTopoLOD(k) {
2704
+ if (nodeLabelSel) nodeLabelSel.style('opacity', k > 0.5 ? Math.min(1, (k - 0.5) * 2) : 0);
2705
+ if (linkLabelSel) linkLabelSel.style('opacity', k > 1.2 ? Math.min(1, (k - 1.2) * 3) : 0);
2706
+ d3.selectAll('.hull-label').style('font-size', k < 0.4 ? '18px' : '13px');
2707
+ }
2708
+
2709
+ function updateTopoVisibility() {
2710
+ if (!nodeSel) return;
2711
+ nodeSel.style('display', function(d) { return layerVisible[d.layer] ? null : 'none'; });
2712
+ linkSel.style('display', function(d) {
2713
+ var s = TOPO.nodes.find(function(n) { return n.id === (d.source.id || d.source); });
2714
+ var t = TOPO.nodes.find(function(n) { return n.id === (d.target.id || d.target); });
2715
+ return (s && layerVisible[s.layer]) && (t && layerVisible[t.layer]) ? null : 'none';
2716
+ });
2717
+ linkLabelSel.style('display', function(d) {
2718
+ var s = TOPO.nodes.find(function(n) { return n.id === (d.source.id || d.source); });
2719
+ var t = TOPO.nodes.find(function(n) { return n.id === (d.target.id || d.target); });
2720
+ return (s && layerVisible[s.layer]) && (t && layerVisible[t.layer]) ? null : 'none';
2721
+ });
2722
+ }
2723
+
2724
+ rebuildTopoGraph();
2725
+ buildTopoList();
2726
+ updateTopoLOD(1);
2727
+
2728
+ svgEl.on('click', function() {
2729
+ topoSelectedId = null;
2730
+ d3.selectAll('.node-hex').classed('selected', false);
2731
+ buildTopoList(document.getElementById('topo-search').value);
2732
+ topoSidebar.innerHTML = '<h2>Infrastructure Map</h2><p class="hint">Click a node to view details.</p>';
2733
+ });
2734
+ }
2735
+
2736
+ // Init topology node list (non-D3 part)
2737
+ buildTopoList();
2809
2738
  </script>
2810
2739
  </body>
2811
2740
  </html>`;
2812
2741
  }
2813
- function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml", "html", "map", "sops"]) {
2742
+ function exportJGF(nodes, edges) {
2743
+ const jgf = {
2744
+ graph: {
2745
+ directed: true,
2746
+ type: "cartography",
2747
+ label: "Infrastructure Map",
2748
+ metadata: { exportedAt: (/* @__PURE__ */ new Date()).toISOString() },
2749
+ nodes: Object.fromEntries(nodes.map((n) => [n.id, {
2750
+ label: n.name,
2751
+ metadata: {
2752
+ type: n.type,
2753
+ layer: nodeLayer(n.type),
2754
+ confidence: n.confidence,
2755
+ discoveredVia: n.discoveredVia,
2756
+ discoveredAt: n.discoveredAt,
2757
+ tags: n.tags,
2758
+ metadata: n.metadata
2759
+ }
2760
+ }])),
2761
+ edges: edges.map((e) => ({
2762
+ source: e.sourceId,
2763
+ target: e.targetId,
2764
+ relation: e.relationship,
2765
+ metadata: { confidence: e.confidence, evidence: e.evidence }
2766
+ }))
2767
+ }
2768
+ };
2769
+ return JSON.stringify(jgf, null, 2);
2770
+ }
2771
+ function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml", "html", "map", "discovery", "sops"]) {
2814
2772
  mkdirSync2(outputDir, { recursive: true });
2815
2773
  mkdirSync2(join2(outputDir, "sops"), { recursive: true });
2816
2774
  mkdirSync2(join2(outputDir, "workflows"), { recursive: true });
2817
2775
  const nodes = db.getNodes(sessionId);
2818
2776
  const edges = db.getEdges(sessionId);
2777
+ const jgfPath = join2(outputDir, "cartography-graph.jgf.json");
2778
+ writeFileSync(jgfPath, exportJGF(nodes, edges));
2779
+ process.stderr.write("\u2713 cartography-graph.jgf.json\n");
2819
2780
  if (formats.includes("mermaid")) {
2820
2781
  writeFileSync(join2(outputDir, "topology.mermaid"), generateTopologyMermaid(nodes, edges));
2821
2782
  writeFileSync(join2(outputDir, "dependencies.mermaid"), generateDependencyMermaid(nodes, edges));
@@ -2829,13 +2790,9 @@ function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml
2829
2790
  writeFileSync(join2(outputDir, "catalog-info.yaml"), exportBackstageYAML(nodes, edges));
2830
2791
  process.stderr.write("\u2713 catalog-info.yaml\n");
2831
2792
  }
2832
- if (formats.includes("html")) {
2833
- writeFileSync(join2(outputDir, "topology.html"), exportHTML(nodes, edges));
2834
- process.stderr.write("\u2713 topology.html\n");
2835
- }
2836
- if (formats.includes("map")) {
2837
- writeFileSync(join2(outputDir, "cartography-map.html"), exportCartographyMap(nodes, edges));
2838
- process.stderr.write("\u2713 cartography-map.html\n");
2793
+ if (formats.includes("html") || formats.includes("map") || formats.includes("discovery")) {
2794
+ writeFileSync(join2(outputDir, "discovery.html"), exportDiscoveryApp(nodes, edges));
2795
+ process.stderr.write("\u2713 discovery.html\n");
2839
2796
  }
2840
2797
  if (formats.includes("sops")) {
2841
2798
  const sops = db.getSOPs(sessionId);
@@ -2853,10 +2810,9 @@ function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml
2853
2810
  }
2854
2811
 
2855
2812
  // src/cli.ts
2856
- import { readFileSync as readFileSync2, existsSync as existsSync2, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
2813
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
2857
2814
  import { resolve } from "path";
2858
2815
  import { createInterface } from "readline";
2859
- import { execSync as execSync2 } from "child_process";
2860
2816
  var bold = (s) => `\x1B[1m${s}\x1B[0m`;
2861
2817
  var dim = (s) => `\x1B[2m${s}\x1B[0m`;
2862
2818
  var cyan = (s) => `\x1B[36m${s}\x1B[0m`;
@@ -3080,27 +3036,12 @@ function main() {
3080
3036
  }
3081
3037
  exportAll(db, sessionId, config.outputDir);
3082
3038
  const osc8 = (url, label) => `\x1B]8;;${url}\x1B\\${label}\x1B]8;;\x1B\\`;
3083
- const htmlPath = resolve(config.outputDir, "topology.html");
3084
- const mapPath = resolve(config.outputDir, "cartography-map.html");
3085
- const topoPath = resolve(config.outputDir, "topology.mermaid");
3039
+ const discoveryPath = resolve(config.outputDir, "discovery.html");
3086
3040
  w("\n");
3087
- if (existsSync2(mapPath)) {
3088
- w(` ${green("\u2192")} ${osc8(`file://${mapPath}`, bold("Open cartography-map.html"))} ${dim("\u2190 Hex Map")}
3089
- `);
3090
- }
3091
- if (existsSync2(htmlPath)) {
3092
- w(` ${green("\u2192")} ${osc8(`file://${htmlPath}`, bold("Open topology.html"))}
3041
+ if (existsSync2(discoveryPath)) {
3042
+ w(` ${green("\u2192")} ${osc8(`file://${discoveryPath}`, bold("Open discovery.html"))} ${dim("\u2190 Map + Topology")}
3093
3043
  `);
3094
3044
  }
3095
- if (existsSync2(topoPath)) {
3096
- try {
3097
- const code = readFileSync2(topoPath, "utf8");
3098
- const b64 = Buffer.from(JSON.stringify({ code, mermaid: { theme: "dark" } })).toString("base64");
3099
- w(` ${cyan("\u2192")} ${osc8(`https://mermaid.live/view#base64:${b64}`, bold("Open in mermaid.live"))}
3100
- `);
3101
- } catch {
3102
- }
3103
- }
3104
3045
  w("\n");
3105
3046
  if (process.stdin.isTTY) {
3106
3047
  w(dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
@@ -3142,8 +3083,8 @@ function main() {
3142
3083
  `);
3143
3084
  w("\n");
3144
3085
  exportAll(db, sessionId, config.outputDir);
3145
- if (existsSync2(htmlPath)) {
3146
- w(` ${green("\u2192")} ${osc8(`file://${htmlPath}`, bold("topology.html updated"))}
3086
+ if (existsSync2(discoveryPath)) {
3087
+ w(` ${green("\u2192")} ${osc8(`file://${discoveryPath}`, bold("discovery.html updated"))}
3147
3088
  `);
3148
3089
  }
3149
3090
  w("\n");
@@ -3167,37 +3108,6 @@ function main() {
3167
3108
  `);
3168
3109
  db.close();
3169
3110
  });
3170
- program.command("map [session-id]").description("Open the interactive Data Cartography hex map in your browser").option("-o, --output <dir>", "Output directory", "./datasynx-output").option("--theme <theme>", "Theme: light or dark", "light").action((sessionId, opts) => {
3171
- const config = defaultConfig({ outputDir: opts.output });
3172
- const db = new CartographyDB(config.dbPath);
3173
- const session = sessionId ? db.getSession(sessionId) : db.getLatestSession();
3174
- if (!session) {
3175
- process.stderr.write("No session found. Run discover first.\n");
3176
- db.close();
3177
- process.exitCode = 1;
3178
- return;
3179
- }
3180
- const nodes = db.getNodes(session.id);
3181
- const edges = db.getEdges(session.id);
3182
- const outDir = resolve(opts.output);
3183
- mkdirSync3(outDir, { recursive: true });
3184
- const outPath = resolve(outDir, "cartography-map.html");
3185
- writeFileSync2(outPath, exportCartographyMap(nodes, edges, { theme: opts.theme }));
3186
- db.close();
3187
- const osc8 = (url, label) => `\x1B]8;;${url}\x1B\\${label}\x1B]8;;\x1B\\`;
3188
- const fileUrl = `file://${outPath}`;
3189
- process.stderr.write(`
3190
- ${green("OK")} ${osc8(fileUrl, bold("Open cartography-map.html"))}
3191
- `);
3192
- process.stderr.write(` ${dim(fileUrl)}
3193
-
3194
- `);
3195
- try {
3196
- const cmd = process.platform === "darwin" ? `open "${outPath}"` : process.platform === "win32" ? `start "" "${outPath}"` : `xdg-open "${outPath}"`;
3197
- execSync2(cmd, { stdio: "ignore" });
3198
- } catch {
3199
- }
3200
- });
3201
3111
  program.command("show [session-id]").description("Show session details").action((sessionId) => {
3202
3112
  const config = defaultConfig();
3203
3113
  const db = new CartographyDB(config.dbPath);
@@ -3440,8 +3350,7 @@ ${infraSummary.substring(0, 12e3)}`;
3440
3350
  out(dim(" catalog-info.yaml Backstage service catalog\n"));
3441
3351
  out(dim(" topology.mermaid Infrastructure topology (graph TB)\n"));
3442
3352
  out(dim(" dependencies.mermaid Service dependencies (graph LR)\n"));
3443
- out(dim(" topology.html Interactive D3.js force graph\n"));
3444
- out(dim(" cartography-map.html Hex grid data cartography map\n"));
3353
+ out(dim(" discovery.html Enterprise discovery frontend (Map + Topology)\n"));
3445
3354
  out(dim(" sops/ Generated SOPs as Markdown\n"));
3446
3355
  out(dim(" workflows/ Workflow flowcharts as Mermaid\n"));
3447
3356
  out("\n");
@@ -3658,7 +3567,7 @@ ${infraSummary.substring(0, 12e3)}`;
3658
3567
  `);
3659
3568
  });
3660
3569
  program.command("doctor").description("Check all requirements and cloud CLIs").action(async () => {
3661
- const { execSync: execSync3 } = await import("child_process");
3570
+ const { execSync: execSync2 } = await import("child_process");
3662
3571
  const { existsSync: existsSync3, readFileSync: readFileSync3 } = await import("fs");
3663
3572
  const { join: join3 } = await import("path");
3664
3573
  const out = (s) => process.stdout.write(s);
@@ -3681,7 +3590,7 @@ ${infraSummary.substring(0, 12e3)}`;
3681
3590
  allGood = false;
3682
3591
  }
3683
3592
  try {
3684
- const v = execSync3("claude --version", { stdio: "pipe" }).toString().trim();
3593
+ const v = execSync2("claude --version", { stdio: "pipe" }).toString().trim();
3685
3594
  ok(`Claude CLI ${dim2(v)}`);
3686
3595
  } catch {
3687
3596
  err("Claude CLI not found \u2014 npm i -g @anthropic-ai/claude-code");
@@ -3705,7 +3614,7 @@ ${infraSummary.substring(0, 12e3)}`;
3705
3614
  allGood = false;
3706
3615
  }
3707
3616
  try {
3708
- const v = execSync3("kubectl version --client --short 2>/dev/null || kubectl version --client", { stdio: "pipe" }).toString().split("\n")[0]?.trim() ?? "";
3617
+ const v = execSync2("kubectl version --client --short 2>/dev/null || kubectl version --client", { stdio: "pipe" }).toString().split("\n")[0]?.trim() ?? "";
3709
3618
  ok(`kubectl ${dim2(v || "(client OK)")}`);
3710
3619
  } catch {
3711
3620
  warn(`kubectl not found ${dim2("\u2014 install: https://kubernetes.io/docs/tasks/tools/")}`);
@@ -3717,7 +3626,7 @@ ${infraSummary.substring(0, 12e3)}`;
3717
3626
  ];
3718
3627
  for (const [name, cmd, hint] of cloudClis) {
3719
3628
  try {
3720
- execSync3(cmd, { stdio: "pipe" });
3629
+ execSync2(cmd, { stdio: "pipe" });
3721
3630
  ok(`${name} ${dim2("(cloud scanning available)")}`);
3722
3631
  } catch {
3723
3632
  warn(`${name} not found ${dim2("\u2014 cloud scan skipped | " + hint)}`);
@@ -3729,7 +3638,7 @@ ${infraSummary.substring(0, 12e3)}`;
3729
3638
  ];
3730
3639
  for (const [name, cmd] of localTools) {
3731
3640
  try {
3732
- execSync3(cmd, { stdio: "pipe" });
3641
+ execSync2(cmd, { stdio: "pipe" });
3733
3642
  ok(`${name} ${dim2("(discovery tool)")}`);
3734
3643
  } catch {
3735
3644
  warn(`${name} not found ${dim2("\u2014 discovery without " + name + " will be limited")}`);