@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/index.js CHANGED
@@ -2015,525 +2015,6 @@ function exportJSON(db, sessionId) {
2015
2015
  sops
2016
2016
  }, null, 2);
2017
2017
  }
2018
- function exportHTML(nodes, edges) {
2019
- const graphData = JSON.stringify({
2020
- nodes: nodes.map((n) => ({
2021
- id: n.id,
2022
- name: n.name,
2023
- type: n.type,
2024
- layer: nodeLayer(n.type),
2025
- confidence: n.confidence,
2026
- discoveredVia: n.discoveredVia,
2027
- discoveredAt: n.discoveredAt,
2028
- tags: n.tags,
2029
- metadata: n.metadata
2030
- })),
2031
- links: edges.map((e) => ({
2032
- source: e.sourceId,
2033
- target: e.targetId,
2034
- relationship: e.relationship,
2035
- confidence: e.confidence,
2036
- evidence: e.evidence
2037
- }))
2038
- });
2039
- return `<!DOCTYPE html>
2040
- <html lang="en">
2041
- <head>
2042
- <meta charset="UTF-8">
2043
- <title>Cartography \u2014 Infrastructure Map</title>
2044
- <script src="https://d3js.org/d3.v7.min.js"></script>
2045
- <style>
2046
- * { box-sizing: border-box; margin: 0; padding: 0; }
2047
- body { background: #0a0e14; color: #e6edf3; font-family: 'SF Mono','Fira Code','Cascadia Code',monospace; display: flex; overflow: hidden; height: 100vh; }
2048
-
2049
- /* \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 */
2050
- #node-panel {
2051
- width: 220px; min-width: 220px; height: 100vh; overflow: hidden;
2052
- background: #0d1117; border-right: 1px solid #1b2028;
2053
- display: flex; flex-direction: column;
2054
- }
2055
- #node-panel-header {
2056
- padding: 10px 12px 8px; border-bottom: 1px solid #1b2028;
2057
- font-size: 11px; color: #6e7681; text-transform: uppercase; letter-spacing: 0.6px;
2058
- }
2059
- #node-search {
2060
- width: calc(100% - 16px); margin: 8px; padding: 5px 8px;
2061
- background: #161b22; border: 1px solid #30363d; border-radius: 5px;
2062
- color: #e6edf3; font-size: 11px; font-family: inherit; outline: none;
2063
- }
2064
- #node-search:focus { border-color: #58a6ff; }
2065
- #node-list { flex: 1; overflow-y: auto; padding-bottom: 8px; }
2066
- .node-list-item {
2067
- padding: 5px 12px; cursor: pointer; font-size: 11px;
2068
- display: flex; align-items: center; gap: 6px; border-left: 2px solid transparent;
2069
- }
2070
- .node-list-item:hover { background: #161b22; }
2071
- .node-list-item.active { background: #1a2436; border-left-color: #58a6ff; }
2072
- .node-list-dot { width: 7px; height: 7px; border-radius: 2px; flex-shrink: 0; }
2073
- .node-list-name { color: #c9d1d9; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
2074
- .node-list-type { color: #484f58; font-size: 9px; flex-shrink: 0; }
2075
-
2076
- /* \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 */
2077
- #graph { flex: 1; height: 100vh; position: relative; }
2078
- svg { width: 100%; height: 100%; }
2079
- .hull { opacity: 0.12; stroke-width: 1.5; stroke-opacity: 0.25; }
2080
- .hull-label { font-size: 13px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; fill-opacity: 0.5; pointer-events: none; }
2081
- .link { stroke-opacity: 0.4; }
2082
- .link-label { font-size: 8px; fill: #6e7681; pointer-events: none; opacity: 0; }
2083
- .node-hex { stroke-width: 1.8; cursor: pointer; transition: opacity 0.15s; }
2084
- .node-hex:hover { filter: brightness(1.3); stroke-width: 3; }
2085
- .node-hex.selected { stroke-width: 3.5; filter: brightness(1.5); }
2086
- .node-label { font-size: 10px; fill: #c9d1d9; pointer-events: none; opacity: 0; }
2087
-
2088
- /* \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 */
2089
- #sidebar {
2090
- width: 300px; min-width: 300px; height: 100vh; overflow-y: auto;
2091
- background: #0d1117; border-left: 1px solid #1b2028;
2092
- padding: 16px; font-size: 12px; line-height: 1.6;
2093
- }
2094
- #sidebar h2 { margin: 0 0 8px; font-size: 14px; color: #58a6ff; }
2095
- #sidebar .meta-table { width: 100%; border-collapse: collapse; }
2096
- #sidebar .meta-table td { padding: 3px 6px; border-bottom: 1px solid #161b22; vertical-align: top; }
2097
- #sidebar .meta-table td:first-child { color: #6e7681; white-space: nowrap; width: 90px; }
2098
- #sidebar .tag { display: inline-block; background: #161b22; border-radius: 3px; padding: 1px 5px; margin: 1px; font-size: 10px; }
2099
- #sidebar .conf-bar { height: 5px; border-radius: 3px; background: #161b22; margin-top: 3px; }
2100
- #sidebar .conf-fill { height: 100%; border-radius: 3px; }
2101
- #sidebar .edges-list { margin-top: 12px; }
2102
- #sidebar .edge-item { padding: 4px 0; border-bottom: 1px solid #161b22; color: #6e7681; font-size: 11px; }
2103
- #sidebar .edge-item span { color: #c9d1d9; }
2104
- #sidebar .action-row { display: flex; gap: 6px; margin-top: 14px; }
2105
- .btn-delete {
2106
- flex: 1; padding: 6px 10px; background: transparent; border: 1px solid #6e191d;
2107
- color: #f85149; border-radius: 5px; font-size: 11px; font-family: inherit;
2108
- cursor: pointer; text-align: center;
2109
- }
2110
- .btn-delete:hover { background: #3d0c0c; }
2111
- .hint { color: #3d434b; font-size: 11px; margin-top: 8px; }
2112
-
2113
- /* \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 */
2114
- #hud { position: absolute; top: 10px; left: 10px; background: rgba(10,14,20,0.88);
2115
- padding: 10px 14px; border-radius: 8px; font-size: 12px; border: 1px solid #1b2028; pointer-events: none; }
2116
- #hud strong { color: #58a6ff; }
2117
- #hud .stats { color: #6e7681; }
2118
- #hud .zoom-level { color: #3d434b; font-size: 10px; margin-top: 2px; }
2119
-
2120
- /* \u2500\u2500 Toolbar (filters + JGF export) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2121
- #toolbar { position: absolute; top: 10px; right: 10px; display: flex; flex-wrap: wrap; gap: 4px; pointer-events: auto; align-items: center; }
2122
- .filter-btn {
2123
- background: rgba(10,14,20,0.85); border: 1px solid #1b2028; border-radius: 6px;
2124
- color: #c9d1d9; padding: 4px 10px; font-size: 11px; cursor: pointer;
2125
- font-family: inherit; display: flex; align-items: center; gap: 5px;
2126
- }
2127
- .filter-btn:hover { border-color: #30363d; }
2128
- .filter-btn.off { opacity: 0.35; }
2129
- .filter-dot { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
2130
- .export-btn {
2131
- background: rgba(10,14,20,0.85); border: 1px solid #1b2028; border-radius: 6px;
2132
- color: #58a6ff; padding: 4px 12px; font-size: 11px; cursor: pointer;
2133
- font-family: inherit;
2134
- }
2135
- .export-btn:hover { border-color: #58a6ff; background: rgba(88,166,255,0.08); }
2136
- </style>
2137
- </head>
2138
- <body>
2139
-
2140
- <!-- Left: node list panel -->
2141
- <div id="node-panel">
2142
- <div id="node-panel-header">Nodes (${nodes.length})</div>
2143
- <input id="node-search" type="text" placeholder="Search nodes\u2026" autocomplete="off" spellcheck="false">
2144
- <div id="node-list"></div>
2145
- </div>
2146
-
2147
- <!-- Center: graph -->
2148
- <div id="graph">
2149
- <div id="hud">
2150
- <strong>Cartography</strong> &nbsp;
2151
- <span class="stats" id="hud-stats">${nodes.length} nodes \xB7 ${edges.length} edges</span><br>
2152
- <span class="zoom-level">Scroll = zoom \xB7 Drag = pan \xB7 Click = details</span>
2153
- </div>
2154
- <div id="toolbar"></div>
2155
- <svg></svg>
2156
- </div>
2157
-
2158
- <!-- Right: detail sidebar -->
2159
- <div id="sidebar">
2160
- <h2>Infrastructure Map</h2>
2161
- <p class="hint">Click a node to view details.</p>
2162
- </div>
2163
-
2164
- <script>
2165
- const data = ${graphData};
2166
-
2167
- // \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
2168
- const TYPE_COLORS = {
2169
- host: '#4a9eff', database_server: '#ff6b6b', database: '#ff8c42',
2170
- web_service: '#6bcb77', api_endpoint: '#4d96ff', cache_server: '#ffd93d',
2171
- message_broker: '#c77dff', queue: '#e0aaff', topic: '#9d4edd',
2172
- container: '#48cae4', pod: '#00b4d8', k8s_cluster: '#0077b6',
2173
- config_file: '#adb5bd', saas_tool: '#c084fc', table: '#f97316', unknown: '#6c757d',
2174
- };
2175
-
2176
- const LAYER_COLORS = {
2177
- saas: '#c084fc', web: '#6bcb77', data: '#ff6b6b',
2178
- messaging: '#c77dff', infra: '#4a9eff', config: '#adb5bd', other: '#6c757d',
2179
- };
2180
- const LAYER_NAMES = {
2181
- saas: 'SaaS Tools', web: 'Web / API', data: 'Data Layer',
2182
- messaging: 'Messaging', infra: 'Infrastructure', config: 'Config', other: 'Other',
2183
- };
2184
-
2185
- // \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
2186
- const HEX_SIZE = { saas_tool: 16, host: 18, database_server: 18, k8s_cluster: 20, default: 14 };
2187
- function hexSize(d) { return HEX_SIZE[d.type] || HEX_SIZE.default; }
2188
- function hexPath(size) {
2189
- const pts = [];
2190
- for (let i = 0; i < 6; i++) {
2191
- const angle = (Math.PI / 3) * i - Math.PI / 6;
2192
- pts.push([size * Math.cos(angle), size * Math.sin(angle)]);
2193
- }
2194
- return 'M' + pts.map(p => p.join(',')).join('L') + 'Z';
2195
- }
2196
-
2197
- // \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
2198
- const nodeListEl = document.getElementById('node-list');
2199
- const nodeSearchEl = document.getElementById('node-search');
2200
- let selectedNodeId = null;
2201
-
2202
- function buildNodeList(filter) {
2203
- const q = (filter || '').toLowerCase();
2204
- nodeListEl.innerHTML = '';
2205
- const sorted = [...data.nodes].sort((a, b) => a.name.localeCompare(b.name));
2206
- for (const d of sorted) {
2207
- if (q && !d.name.toLowerCase().includes(q) && !d.type.includes(q) && !d.id.toLowerCase().includes(q)) continue;
2208
- const item = document.createElement('div');
2209
- item.className = 'node-list-item' + (d.id === selectedNodeId ? ' active' : '');
2210
- item.dataset.id = d.id;
2211
- const color = TYPE_COLORS[d.type] || '#aaa';
2212
- item.innerHTML = \`<span class="node-list-dot" style="background:\${color}"></span>
2213
- <span class="node-list-name" title="\${d.id}">\${d.name}</span>
2214
- <span class="node-list-type">\${d.type.replace(/_/g,' ')}</span>\`;
2215
- item.onclick = () => { selectNode(d); focusNode(d); };
2216
- nodeListEl.appendChild(item);
2217
- }
2218
- }
2219
-
2220
- nodeSearchEl.addEventListener('input', e => buildNodeList(e.target.value));
2221
-
2222
- // \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
2223
- const sidebar = document.getElementById('sidebar');
2224
-
2225
- function selectNode(d) {
2226
- selectedNodeId = d.id;
2227
- buildNodeList(nodeSearchEl.value);
2228
- showNode(d);
2229
- // highlight hex
2230
- d3.selectAll('.node-hex').classed('selected', nd => nd.id === d.id);
2231
- }
2232
-
2233
- function showNode(d) {
2234
- const c = TYPE_COLORS[d.type] || '#aaa';
2235
- const confPct = Math.round(d.confidence * 100);
2236
- const tags = (d.tags || []).map(t => \`<span class="tag">\${t}</span>\`).join('');
2237
- const metaRows = Object.entries(d.metadata || {})
2238
- .filter(([,v]) => v !== null && v !== undefined && String(v).length > 0)
2239
- .map(([k,v]) => \`<tr><td>\${k}</td><td>\${JSON.stringify(v)}</td></tr>\`)
2240
- .join('');
2241
- const related = data.links.filter(l =>
2242
- (l.source.id||l.source) === d.id || (l.target.id||l.target) === d.id
2243
- );
2244
- const edgeItems = related.map(l => {
2245
- const isOut = (l.source.id||l.source) === d.id;
2246
- const other = isOut ? (l.target.id||l.target) : (l.source.id||l.source);
2247
- return \`<div class="edge-item">\${isOut ? '\u2192' : '\u2190'} <span>\${other}</span> <small>[\${l.relationship}]</small></div>\`;
2248
- }).join('');
2249
-
2250
- sidebar.innerHTML = \`
2251
- <h2>\${d.name}</h2>
2252
- <table class="meta-table">
2253
- <tr><td>ID</td><td style="font-size:10px;word-break:break-all">\${d.id}</td></tr>
2254
- <tr><td>Type</td><td><span style="color:\${c}">\${d.type}</span></td></tr>
2255
- <tr><td>Layer</td><td>\${d.layer}</td></tr>
2256
- <tr><td>Confidence</td><td>
2257
- \${confPct}%
2258
- <div class="conf-bar"><div class="conf-fill" style="width:\${confPct}%;background:\${c}"></div></div>
2259
- </td></tr>
2260
- <tr><td>Discovered via</td><td>\${d.discoveredVia || '\u2014'}</td></tr>
2261
- <tr><td>Timestamp</td><td>\${d.discoveredAt ? d.discoveredAt.substring(0,19).replace('T',' ') : '\u2014'}</td></tr>
2262
- \${tags ? '<tr><td>Tags</td><td>'+tags+'</td></tr>' : ''}
2263
- \${metaRows}
2264
- </table>
2265
- \${related.length > 0 ? '<div class="edges-list"><strong>Connections (' + related.length + '):</strong>'+edgeItems+'</div>' : ''}
2266
- <div class="action-row">
2267
- <button class="btn-delete" onclick="deleteNode('\${d.id}')">\u{1F5D1} Delete node</button>
2268
- </div>
2269
- \`;
2270
- }
2271
-
2272
- // \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
2273
- function deleteNode(id) {
2274
- const idx = data.nodes.findIndex(n => n.id === id);
2275
- if (idx === -1) return;
2276
- data.nodes.splice(idx, 1);
2277
- data.links = data.links.filter(l =>
2278
- (l.source.id || l.source) !== id && (l.target.id || l.target) !== id
2279
- );
2280
- selectedNodeId = null;
2281
- sidebar.innerHTML = '<h2>Infrastructure Map</h2><p class="hint">Node deleted.</p>';
2282
- document.getElementById('hud-stats').textContent =
2283
- data.nodes.length + ' nodes \xB7 ' + data.links.length + ' edges';
2284
- rebuildGraph();
2285
- buildNodeList(nodeSearchEl.value);
2286
- }
2287
-
2288
- // \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
2289
- const svgEl = d3.select('svg');
2290
- const graphDiv = document.getElementById('graph');
2291
- const W = () => graphDiv.clientWidth;
2292
- const H = () => graphDiv.clientHeight;
2293
- const g = svgEl.append('g');
2294
-
2295
- svgEl.append('defs').append('marker')
2296
- .attr('id', 'arrow').attr('viewBox', '0 0 10 6')
2297
- .attr('refX', 10).attr('refY', 3)
2298
- .attr('markerWidth', 8).attr('markerHeight', 6)
2299
- .attr('orient', 'auto')
2300
- .append('path').attr('d', 'M0,0 L10,3 L0,6 Z').attr('fill', '#555');
2301
-
2302
- let currentZoom = 1;
2303
- const zoomBehavior = d3.zoom().scaleExtent([0.08, 6]).on('zoom', e => {
2304
- g.attr('transform', e.transform);
2305
- currentZoom = e.transform.k;
2306
- updateLOD(currentZoom);
2307
- });
2308
- svgEl.call(zoomBehavior);
2309
-
2310
- // \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
2311
- const layers = [...new Set(data.nodes.map(d => d.layer))];
2312
- const layerVisible = {};
2313
- layers.forEach(l => layerVisible[l] = true);
2314
-
2315
- const toolbarEl = document.getElementById('toolbar');
2316
-
2317
- // Filter buttons
2318
- layers.forEach(layer => {
2319
- const btn = document.createElement('button');
2320
- btn.className = 'filter-btn';
2321
- btn.innerHTML = \`<span class="filter-dot" style="background:\${LAYER_COLORS[layer]||'#666'}"></span>\${LAYER_NAMES[layer]||layer}\`;
2322
- btn.onclick = () => {
2323
- layerVisible[layer] = !layerVisible[layer];
2324
- btn.classList.toggle('off', !layerVisible[layer]);
2325
- updateVisibility();
2326
- };
2327
- toolbarEl.appendChild(btn);
2328
- });
2329
-
2330
- // JGF export button
2331
- const jgfBtn = document.createElement('button');
2332
- jgfBtn.className = 'export-btn';
2333
- jgfBtn.textContent = '\u2193 JGF';
2334
- jgfBtn.title = 'Export JSON Graph Format';
2335
- jgfBtn.onclick = exportJGF;
2336
- toolbarEl.appendChild(jgfBtn);
2337
-
2338
- // \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
2339
- function exportJGF() {
2340
- const jgf = {
2341
- graph: {
2342
- directed: true,
2343
- type: 'cartography',
2344
- label: 'Infrastructure Map',
2345
- metadata: { exportedAt: new Date().toISOString() },
2346
- nodes: Object.fromEntries(data.nodes.map(n => [n.id, {
2347
- label: n.name,
2348
- metadata: { type: n.type, layer: n.layer, confidence: n.confidence,
2349
- discoveredVia: n.discoveredVia, discoveredAt: n.discoveredAt,
2350
- tags: n.tags, ...n.metadata }
2351
- }])),
2352
- edges: data.links.map(l => ({
2353
- source: l.source.id || l.source,
2354
- target: l.target.id || l.target,
2355
- relation: l.relationship,
2356
- metadata: { confidence: l.confidence, evidence: l.evidence }
2357
- })),
2358
- }
2359
- };
2360
- const blob = new Blob([JSON.stringify(jgf, null, 2)], { type: 'application/json' });
2361
- const url = URL.createObjectURL(blob);
2362
- const a = document.createElement('a');
2363
- a.href = url; a.download = 'cartography-graph.jgf.json'; a.click();
2364
- URL.revokeObjectURL(url);
2365
- }
2366
-
2367
- // \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
2368
- function clusterForce(alpha) {
2369
- const centroids = {};
2370
- const counts = {};
2371
- data.nodes.forEach(d => {
2372
- if (!centroids[d.layer]) { centroids[d.layer] = { x: 0, y: 0 }; counts[d.layer] = 0; }
2373
- centroids[d.layer].x += d.x || 0;
2374
- centroids[d.layer].y += d.y || 0;
2375
- counts[d.layer]++;
2376
- });
2377
- for (const l in centroids) { centroids[l].x /= counts[l]; centroids[l].y /= counts[l]; }
2378
- const strength = alpha * 0.15;
2379
- data.nodes.forEach(d => {
2380
- const c = centroids[d.layer];
2381
- if (c) { d.vx += (c.x - d.x) * strength; d.vy += (c.y - d.y) * strength; }
2382
- });
2383
- }
2384
-
2385
- // \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
2386
- const hullGroup = g.append('g').attr('class', 'hulls');
2387
- const hullPaths = {};
2388
- const hullLabels = {};
2389
- layers.forEach(layer => {
2390
- hullPaths[layer] = hullGroup.append('path').attr('class', 'hull')
2391
- .attr('fill', LAYER_COLORS[layer] || '#666').attr('stroke', LAYER_COLORS[layer] || '#666');
2392
- hullLabels[layer] = hullGroup.append('text').attr('class', 'hull-label')
2393
- .attr('fill', LAYER_COLORS[layer] || '#666').text(LAYER_NAMES[layer] || layer);
2394
- });
2395
-
2396
- function updateHulls() {
2397
- layers.forEach(layer => {
2398
- if (!layerVisible[layer]) { hullPaths[layer].attr('d', null); hullLabels[layer].attr('x', -9999); return; }
2399
- const pts = data.nodes.filter(d => d.layer === layer && layerVisible[d.layer]).map(d => [d.x, d.y]);
2400
- if (pts.length < 3) {
2401
- hullPaths[layer].attr('d', null);
2402
- if (pts.length > 0) hullLabels[layer].attr('x', pts[0][0]).attr('y', pts[0][1] - 30);
2403
- else hullLabels[layer].attr('x', -9999);
2404
- return;
2405
- }
2406
- const hull = d3.polygonHull(pts);
2407
- if (!hull) { hullPaths[layer].attr('d', null); return; }
2408
- const cx = d3.mean(hull, p => p[0]);
2409
- const cy = d3.mean(hull, p => p[1]);
2410
- const padded = hull.map(p => {
2411
- const dx = p[0] - cx, dy = p[1] - cy;
2412
- const len = Math.sqrt(dx*dx + dy*dy) || 1;
2413
- return [p[0] + dx/len * 40, p[1] + dy/len * 40];
2414
- });
2415
- hullPaths[layer].attr('d', 'M' + padded.join('L') + 'Z');
2416
- hullLabels[layer].attr('x', cx).attr('y', cy - d3.max(hull, p => Math.abs(p[1] - cy)) - 30);
2417
- });
2418
- }
2419
-
2420
- // \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
2421
- let linkSel, linkLabelSel, nodeSel, nodeLabelSel, sim;
2422
- const linkGroup = g.append('g');
2423
- const nodeGroup = g.append('g');
2424
-
2425
- function focusNode(d) {
2426
- if (!d.x || !d.y) return;
2427
- const w = W(), h = H();
2428
- svgEl.transition().duration(500).call(
2429
- zoomBehavior.transform,
2430
- d3.zoomIdentity.translate(w / 2, h / 2).scale(Math.min(3, currentZoom < 1 ? 1.5 : currentZoom)).translate(-d.x, -d.y)
2431
- );
2432
- }
2433
-
2434
- function rebuildGraph() {
2435
- if (sim) sim.stop();
2436
-
2437
- // Links
2438
- linkSel = linkGroup.selectAll('line').data(data.links, d => \`\${d.source.id||d.source}>\${d.target.id||d.target}\`);
2439
- linkSel.exit().remove();
2440
- const linkEnter = linkSel.enter().append('line').attr('class', 'link');
2441
- linkSel = linkEnter.merge(linkSel)
2442
- .attr('stroke', d => d.confidence < 0.6 ? '#2a2e35' : '#3d434b')
2443
- .attr('stroke-dasharray', d => d.confidence < 0.6 ? '4 3' : null)
2444
- .attr('stroke-width', d => d.confidence < 0.6 ? 0.8 : 1.2)
2445
- .attr('marker-end', 'url(#arrow)');
2446
- linkSel.select('title').remove();
2447
- linkSel.append('title').text(d => \`\${d.relationship} (\${Math.round(d.confidence*100)}%)
2448
- \${d.evidence||''}\`);
2449
-
2450
- // Link labels
2451
- linkLabelSel = linkGroup.selectAll('text').data(data.links, d => \`\${d.source.id||d.source}>\${d.target.id||d.target}\`);
2452
- linkLabelSel.exit().remove();
2453
- linkLabelSel = linkLabelSel.enter().append('text').attr('class', 'link-label').merge(linkLabelSel)
2454
- .text(d => d.relationship);
2455
-
2456
- // Nodes
2457
- nodeSel = nodeGroup.selectAll('g').data(data.nodes, d => d.id);
2458
- nodeSel.exit().remove();
2459
- const nodeEnter = nodeSel.enter().append('g')
2460
- .call(d3.drag()
2461
- .on('start', (e, d) => { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
2462
- .on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
2463
- .on('end', (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; })
2464
- )
2465
- .on('click', (e, d) => { e.stopPropagation(); selectNode(d); });
2466
- nodeEnter.append('path').attr('class', 'node-hex');
2467
- nodeEnter.append('title');
2468
- nodeEnter.append('text').attr('class', 'node-label').attr('text-anchor', 'middle');
2469
-
2470
- nodeSel = nodeEnter.merge(nodeSel);
2471
- nodeSel.select('.node-hex')
2472
- .attr('d', d => hexPath(hexSize(d)))
2473
- .attr('fill', d => TYPE_COLORS[d.type] || '#aaa')
2474
- .attr('stroke', d => { const c = d3.color(TYPE_COLORS[d.type] || '#aaa'); return c ? c.brighter(0.8).formatHex() : '#ccc'; })
2475
- .attr('fill-opacity', d => 0.6 + d.confidence * 0.4)
2476
- .classed('selected', d => d.id === selectedNodeId);
2477
- nodeSel.select('title').text(d => \`\${d.name} (\${d.type})
2478
- conf: \${Math.round(d.confidence*100)}%\`);
2479
- nodeLabelSel = nodeSel.select('.node-label')
2480
- .attr('dy', d => hexSize(d) + 13)
2481
- .text(d => d.name.length > 20 ? d.name.substring(0, 18) + '\u2026' : d.name);
2482
-
2483
- // Simulation
2484
- sim = d3.forceSimulation(data.nodes)
2485
- .force('link', d3.forceLink(data.links).id(d => d.id).distance(d => d.relationship === 'contains' ? 50 : 100).strength(0.4))
2486
- .force('charge', d3.forceManyBody().strength(-280))
2487
- .force('center', d3.forceCenter(W() / 2, H() / 2))
2488
- .force('collision', d3.forceCollide().radius(d => hexSize(d) + 10))
2489
- .force('cluster', clusterForce)
2490
- .on('tick', () => {
2491
- updateHulls();
2492
- linkSel.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
2493
- .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
2494
- linkLabelSel.attr('x', d => (d.source.x + d.target.x) / 2)
2495
- .attr('y', d => (d.source.y + d.target.y) / 2 - 4);
2496
- nodeSel.attr('transform', d => \`translate(\${d.x},\${d.y})\`);
2497
- });
2498
- }
2499
-
2500
- // \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
2501
- function updateLOD(k) {
2502
- if (nodeLabelSel) nodeLabelSel.style('opacity', k > 0.5 ? Math.min(1, (k - 0.5) * 2) : 0);
2503
- if (linkLabelSel) linkLabelSel.style('opacity', k > 1.2 ? Math.min(1, (k - 1.2) * 3) : 0);
2504
- d3.selectAll('.hull-label').style('font-size', k < 0.4 ? '18px' : '13px');
2505
- }
2506
-
2507
- function updateVisibility() {
2508
- if (!nodeSel) return;
2509
- nodeSel.style('display', d => layerVisible[d.layer] ? null : 'none');
2510
- linkSel.style('display', d => {
2511
- const s = data.nodes.find(n => n.id === (d.source.id||d.source));
2512
- const t = data.nodes.find(n => n.id === (d.target.id||d.target));
2513
- return (s && layerVisible[s.layer]) && (t && layerVisible[t.layer]) ? null : 'none';
2514
- });
2515
- linkLabelSel.style('display', d => {
2516
- const s = data.nodes.find(n => n.id === (d.source.id||d.source));
2517
- const t = data.nodes.find(n => n.id === (d.target.id||d.target));
2518
- return (s && layerVisible[s.layer]) && (t && layerVisible[t.layer]) ? null : 'none';
2519
- });
2520
- }
2521
-
2522
- // \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
2523
- rebuildGraph();
2524
- buildNodeList();
2525
- updateLOD(1);
2526
-
2527
- svgEl.on('click', () => {
2528
- selectedNodeId = null;
2529
- d3.selectAll('.node-hex').classed('selected', false);
2530
- buildNodeList(nodeSearchEl.value);
2531
- sidebar.innerHTML = '<h2>Infrastructure Map</h2><p class="hint">Click a node to view details.</p>';
2532
- });
2533
- </script>
2534
- </body>
2535
- </html>`;
2536
- }
2537
2018
  function exportSOPMarkdown(sop) {
2538
2019
  const lines = [
2539
2020
  `# ${sop.title}`,
@@ -2746,12 +2227,32 @@ function toggle(i) {
2746
2227
  </body>
2747
2228
  </html>`;
2748
2229
  }
2749
- function exportCartographyMap(nodes, edges, options) {
2750
- const mapData = buildMapData(nodes, edges, options);
2751
- const { assets, clusters, connections, meta } = mapData;
2230
+ function exportDiscoveryApp(nodes, edges, options) {
2231
+ const theme = options?.theme ?? "dark";
2232
+ const graphData = JSON.stringify({
2233
+ nodes: nodes.map((n) => ({
2234
+ id: n.id,
2235
+ name: n.name,
2236
+ type: n.type,
2237
+ layer: nodeLayer(n.type),
2238
+ confidence: n.confidence,
2239
+ discoveredVia: n.discoveredVia,
2240
+ discoveredAt: n.discoveredAt,
2241
+ tags: n.tags,
2242
+ metadata: n.metadata
2243
+ })),
2244
+ links: edges.map((e) => ({
2245
+ source: e.sourceId,
2246
+ target: e.targetId,
2247
+ relationship: e.relationship,
2248
+ confidence: e.confidence,
2249
+ evidence: e.evidence
2250
+ }))
2251
+ });
2252
+ const { assets, clusters, connections } = buildMapData(nodes, edges, { theme });
2752
2253
  const isEmpty = assets.length === 0;
2753
2254
  const HEX_SIZE2 = 24;
2754
- const dataJson = JSON.stringify({
2255
+ const mapJson = JSON.stringify({
2755
2256
  assets: assets.map((a) => ({
2756
2257
  id: a.id,
2757
2258
  name: a.name,
@@ -2777,621 +2278,1081 @@ function exportCartographyMap(nodes, edges, options) {
2777
2278
  type: c.type ?? "connection"
2778
2279
  }))
2779
2280
  });
2281
+ const nodeCount = nodes.length;
2282
+ const edgeCount = edges.length;
2283
+ const assetCount = assets.length;
2284
+ const clusterCount = clusters.length;
2780
2285
  return `<!DOCTYPE html>
2781
- <html lang="en">
2286
+ <html lang="en" data-theme="${theme}">
2782
2287
  <head>
2783
2288
  <meta charset="UTF-8"/>
2784
- <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
2785
- <title>Data Cartography Map</title>
2289
+ <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
2290
+ <title>Cartography \u2014 Datasynx Discovery</title>
2291
+ <script src="https://d3js.org/d3.v7.min.js"></script>
2786
2292
  <style>
2293
+ /* \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 */
2294
+ :root{
2295
+ --bg-base:#0f172a;--bg-surface:#1e293b;--bg-elevated:#273148;
2296
+ --border:#334155;--border-dim:#1e293b;
2297
+ --text:#e2e8f0;--text-muted:#94a3b8;--text-dim:#475569;
2298
+ --accent:#3b82f6;--accent-hover:#2563eb;--accent-dim:rgba(59,130,246,.12);
2299
+ }
2300
+ [data-theme="light"]{
2301
+ --bg-base:#f8fafc;--bg-surface:#ffffff;--bg-elevated:#f1f5f9;
2302
+ --border:#e2e8f0;--border-dim:#f1f5f9;
2303
+ --text:#0f172a;--text-muted:#64748b;--text-dim:#94a3b8;
2304
+ --accent:#2563eb;--accent-hover:#1d4ed8;--accent-dim:rgba(37,99,235,.08);
2305
+ }
2306
+
2307
+ /* \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 */
2787
2308
  *{box-sizing:border-box;margin:0;padding:0}
2788
- html,body{width:100%;height:100%;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
2789
- body{display:flex;flex-direction:column;background:${meta.theme === "dark" ? "#0f172a" : "#f8fafc"};color:${meta.theme === "dark" ? "#e2e8f0" : "#1e293b"}}
2309
+ html,body{width:100%;height:100%;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Inter',sans-serif}
2310
+ body{display:flex;flex-direction:column;background:var(--bg-base);color:var(--text)}
2311
+
2312
+ /* \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 */
2790
2313
  #topbar{
2791
- height:48px;display:flex;align-items:center;gap:16px;padding:0 20px;
2792
- background:${meta.theme === "dark" ? "#1e293b" : "#fff"};border-bottom:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};z-index:10;flex-shrink:0;
2793
- }
2794
- #topbar h1{font-size:15px;font-weight:600;letter-spacing:-0.01em}
2795
- #search-box{
2796
- display:flex;align-items:center;gap:8px;background:${meta.theme === "dark" ? "#334155" : "#f1f5f9"};
2797
- border-radius:8px;padding:5px 10px;margin-left:auto;
2798
- }
2799
- #search-box input{
2800
- border:none;background:transparent;font-size:13px;outline:none;width:180px;color:inherit;
2801
- }
2802
- #search-box input::placeholder{color:#94a3b8}
2803
- #main{flex:1;display:flex;overflow:hidden;position:relative}
2804
- #canvas-wrap{flex:1;position:relative;overflow:hidden;cursor:grab}
2805
- #canvas-wrap.dragging{cursor:grabbing}
2806
- #canvas-wrap.connecting{cursor:crosshair}
2807
- canvas{display:block;width:100%;height:100%}
2808
- /* Detail panel */
2809
- #detail-panel{
2810
- width:280px;background:${meta.theme === "dark" ? "#1e293b" : "#fff"};border-left:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};
2314
+ height:56px;display:flex;align-items:center;gap:16px;padding:0 20px;
2315
+ background:var(--bg-surface);border-bottom:1px solid var(--border);z-index:100;flex-shrink:0;
2316
+ }
2317
+ .tb-left{display:flex;align-items:center;gap:10px}
2318
+ .brand-logo{flex-shrink:0}
2319
+ .brand-name{font-size:15px;font-weight:700;color:var(--accent);letter-spacing:-.02em}
2320
+ .brand-product{font-size:14px;font-weight:500;color:var(--text-muted);margin-left:2px}
2321
+ .brand-sep{width:1px;height:24px;background:var(--border);margin:0 6px}
2322
+ .tb-center{display:flex;align-items:center;gap:2px;margin-left:auto;
2323
+ background:var(--bg-elevated);border-radius:8px;padding:3px}
2324
+ .tab-btn{
2325
+ padding:6px 16px;border:none;border-radius:6px;font-size:13px;font-weight:500;
2326
+ cursor:pointer;color:var(--text-muted);background:transparent;font-family:inherit;
2327
+ transition:all .15s;
2328
+ }
2329
+ .tab-btn:hover{color:var(--text)}
2330
+ .tab-btn.active{background:var(--accent);color:#fff;box-shadow:0 1px 3px rgba(0,0,0,.2)}
2331
+ .tb-right{display:flex;align-items:center;gap:8px;margin-left:auto}
2332
+ .tb-search{
2333
+ display:flex;align-items:center;gap:6px;background:var(--bg-elevated);
2334
+ border:1px solid var(--border);border-radius:8px;padding:5px 10px;
2335
+ }
2336
+ .tb-search input{
2337
+ border:none;background:transparent;font-size:13px;outline:none;width:160px;
2338
+ color:var(--text);font-family:inherit;
2339
+ }
2340
+ .tb-search input::placeholder{color:var(--text-dim)}
2341
+ .tb-search svg{flex-shrink:0;color:var(--text-dim)}
2342
+ .icon-btn{
2343
+ width:36px;height:36px;border-radius:8px;border:1px solid var(--border);
2344
+ background:var(--bg-surface);cursor:pointer;display:flex;align-items:center;
2345
+ justify-content:center;color:var(--text-muted);text-decoration:none;transition:all .15s;font-size:16px;
2346
+ }
2347
+ .icon-btn:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-dim)}
2348
+ .tb-stats{font-size:11px;color:var(--text-dim);white-space:nowrap}
2349
+
2350
+ /* \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 */
2351
+ .view{flex:1;display:none;overflow:hidden;position:relative}
2352
+ .view.active{display:flex}
2353
+
2354
+ /* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2355
+ MAP VIEW
2356
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 */
2357
+ #map-wrap{flex:1;position:relative;overflow:hidden;cursor:grab}
2358
+ #map-wrap.dragging{cursor:grabbing}
2359
+ #map-wrap.connecting{cursor:crosshair}
2360
+ #map-wrap canvas{display:block;width:100%;height:100%}
2361
+ #map-detail{
2362
+ width:280px;background:var(--bg-surface);border-left:1px solid var(--border);
2811
2363
  display:flex;flex-direction:column;transform:translateX(100%);
2812
2364
  transition:transform .2s ease;z-index:5;flex-shrink:0;overflow-y:auto;
2813
2365
  }
2814
- #detail-panel.open{transform:translateX(0)}
2815
- #detail-panel .panel-header{
2816
- padding:16px;border-bottom:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};display:flex;align-items:center;gap:10px;
2366
+ #map-detail.open{transform:translateX(0)}
2367
+ #map-detail .panel-header{
2368
+ padding:16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;
2817
2369
  }
2818
- #detail-panel .panel-header h3{font-size:14px;font-weight:600;flex:1;word-break:break-word}
2819
- #detail-panel .close-btn{
2370
+ #map-detail .panel-header h3{font-size:14px;font-weight:600;flex:1;word-break:break-word}
2371
+ .close-btn{
2820
2372
  width:24px;height:24px;border:none;background:transparent;cursor:pointer;
2821
- color:#94a3b8;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:16px;
2822
- }
2823
- #detail-panel .close-btn:hover{background:${meta.theme === "dark" ? "#334155" : "#f1f5f9"}}
2824
- #detail-panel .panel-body{padding:12px 16px;display:flex;flex-direction:column;gap:12px}
2825
- #detail-panel .meta-row{display:flex;flex-direction:column;gap:3px}
2826
- #detail-panel .meta-label{font-size:11px;font-weight:500;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em}
2827
- #detail-panel .meta-value{font-size:13px;word-break:break-all}
2828
- #detail-panel .quality-bar{height:6px;border-radius:3px;background:${meta.theme === "dark" ? "#334155" : "#e2e8f0"};margin-top:4px}
2829
- #detail-panel .quality-fill{height:6px;border-radius:3px;transition:width .3s}
2830
- /* Bottom-left toolbar */
2831
- #toolbar-left{
2832
- position:absolute;bottom:20px;left:20px;display:flex;gap:8px;z-index:10;
2833
- }
2834
- .tb-btn{
2835
- width:40px;height:40px;border-radius:10px;border:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};
2836
- background:${meta.theme === "dark" ? "#1e293b" : "#fff"};box-shadow:0 1px 4px rgba(0,0,0,.08);cursor:pointer;
2373
+ color:var(--text-muted);border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:16px;
2374
+ }
2375
+ .close-btn:hover{background:var(--bg-elevated)}
2376
+ .panel-body{padding:12px 16px;display:flex;flex-direction:column;gap:12px}
2377
+ .meta-row{display:flex;flex-direction:column;gap:3px}
2378
+ .meta-label{font-size:11px;font-weight:500;color:var(--text-dim);text-transform:uppercase;letter-spacing:.05em}
2379
+ .meta-value{font-size:13px;word-break:break-all}
2380
+ .quality-bar{height:6px;border-radius:3px;background:var(--bg-elevated);margin-top:4px}
2381
+ .quality-fill{height:6px;border-radius:3px;transition:width .3s}
2382
+
2383
+ /* Map toolbars */
2384
+ #map-tb-left{position:absolute;bottom:20px;left:20px;display:flex;gap:8px;z-index:10}
2385
+ #map-tb-right{position:absolute;bottom:20px;right:20px;display:flex;flex-direction:column;align-items:flex-end;gap:8px;z-index:10}
2386
+ .tb-tool{
2387
+ width:40px;height:40px;border-radius:10px;border:1px solid var(--border);
2388
+ background:var(--bg-surface);box-shadow:0 1px 4px rgba(0,0,0,.08);cursor:pointer;
2837
2389
  display:flex;align-items:center;justify-content:center;font-size:18px;
2838
- transition:all .15s;color:inherit;
2390
+ transition:all .15s;color:var(--text);font-family:-apple-system,sans-serif;
2839
2391
  }
2840
- .tb-btn:hover{border-color:#94a3b8}
2841
- .tb-btn.active{background:${meta.theme === "dark" ? "#1e3a5f" : "#eff6ff"};border-color:#3b82f6}
2842
- /* Bottom-right toolbar */
2843
- #toolbar-right{
2844
- position:absolute;bottom:20px;right:20px;display:flex;flex-direction:column;
2845
- align-items:flex-end;gap:8px;z-index:10;
2846
- }
2847
- #zoom-controls{display:flex;align-items:center;gap:6px}
2392
+ #btn-all-labels{font-size:14px;font-weight:700;letter-spacing:-.02em}
2393
+ .tb-tool:hover{border-color:var(--text-muted)}
2394
+ .tb-tool.active{background:var(--accent-dim);border-color:var(--accent)}
2395
+ .map-zoom{display:flex;align-items:center;gap:6px}
2848
2396
  .zoom-btn{
2849
- width:34px;height:34px;border-radius:8px;border:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};
2850
- background:${meta.theme === "dark" ? "#1e293b" : "#fff"};cursor:pointer;
2851
- font-size:18px;color:inherit;display:flex;align-items:center;justify-content:center;
2852
- }
2853
- .zoom-btn:hover{background:${meta.theme === "dark" ? "#334155" : "#f1f5f9"}}
2854
- #zoom-pct{font-size:12px;font-weight:500;color:#64748b;min-width:38px;text-align:center}
2855
- #detail-selector{display:flex;flex-direction:column;gap:4px}
2856
- .detail-btn{
2857
- width:34px;height:34px;border-radius:8px;border:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};
2858
- background:${meta.theme === "dark" ? "#1e293b" : "#fff"};cursor:pointer;
2859
- font-size:12px;font-weight:600;color:#64748b;display:flex;align-items:center;justify-content:center;
2860
- }
2861
- .detail-btn:hover{background:${meta.theme === "dark" ? "#334155" : "#f1f5f9"}}
2862
- .detail-btn.active{background:${meta.theme === "dark" ? "#1e3a5f" : "#eff6ff"};border-color:#3b82f6;color:#2563eb}
2863
- #connect-btn{
2864
- width:40px;height:40px;border-radius:10px;border:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};
2865
- background:${meta.theme === "dark" ? "#1e293b" : "#fff"};cursor:pointer;
2866
- font-size:18px;display:flex;align-items:center;justify-content:center;color:inherit;
2867
- }
2868
- #connect-btn.active{background:#fef3c7;border-color:#f59e0b}
2869
- /* Tooltip */
2870
- #tooltip{
2871
- position:fixed;background:#1e293b;color:#fff;border-radius:8px;
2872
- padding:8px 12px;font-size:12px;pointer-events:none;z-index:100;
2873
- display:none;max-width:220px;box-shadow:0 4px 12px rgba(0,0,0,.15);
2874
- }
2875
- #tooltip .tt-name{font-weight:600;margin-bottom:2px}
2876
- #tooltip .tt-domain{color:#94a3b8;font-size:11px}
2877
- #tooltip .tt-quality{font-size:11px;margin-top:2px}
2878
- /* Empty state */
2879
- #empty-state{
2880
- position:absolute;inset:0;display:flex;flex-direction:column;
2881
- align-items:center;justify-content:center;gap:12px;color:#94a3b8;
2882
- }
2883
- #empty-state p{font-size:14px}
2884
- /* Theme toggle */
2885
- #theme-btn{
2886
- width:40px;height:40px;border-radius:10px;border:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};
2887
- background:${meta.theme === "dark" ? "#1e293b" : "#fff"};cursor:pointer;
2888
- font-size:18px;display:flex;align-items:center;justify-content:center;color:inherit;
2889
- }
2890
- /* Dark mode overrides (toggled via JS) */
2891
- body.dark{background:#0f172a;color:#e2e8f0}
2892
- body.dark #topbar{background:#1e293b;border-color:#334155}
2893
- body.dark #search-box{background:#334155}
2894
- body.dark #detail-panel{background:#1e293b;border-color:#334155}
2895
- body.dark .tb-btn,body.dark .zoom-btn,body.dark .detail-btn,body.dark #connect-btn,body.dark #theme-btn{
2896
- background:#1e293b;border-color:#334155;color:#e2e8f0;
2897
- }
2898
- /* Light mode overrides */
2899
- body.light{background:#f8fafc;color:#1e293b}
2900
- body.light #topbar{background:#fff;border-color:#e2e8f0}
2901
- /* Connection hint */
2902
- #connect-hint{
2397
+ width:34px;height:34px;border-radius:8px;border:1px solid var(--border);
2398
+ background:var(--bg-surface);cursor:pointer;font-size:18px;color:var(--text);
2399
+ display:flex;align-items:center;justify-content:center;
2400
+ }
2401
+ .zoom-btn:hover{background:var(--bg-elevated)}
2402
+ #map-zoom-pct{font-size:12px;font-weight:500;color:var(--text-dim);min-width:38px;text-align:center}
2403
+ #map-connect-hint{
2903
2404
  position:absolute;top:12px;left:50%;transform:translateX(-50%);
2904
2405
  background:#fef3c7;border:1px solid #f59e0b;color:#92400e;
2905
2406
  padding:6px 14px;border-radius:20px;font-size:12px;font-weight:500;
2906
2407
  display:none;z-index:20;pointer-events:none;
2907
2408
  }
2908
- /* Screen reader only */
2909
- .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}
2409
+ #map-tooltip{
2410
+ position:fixed;background:var(--bg-surface);color:var(--text);border-radius:8px;
2411
+ padding:8px 12px;font-size:12px;pointer-events:none;z-index:200;
2412
+ display:none;max-width:220px;box-shadow:0 4px 12px rgba(0,0,0,.25);border:1px solid var(--border);
2413
+ }
2414
+ #map-tooltip .tt-name{font-weight:600;margin-bottom:2px}
2415
+ #map-tooltip .tt-domain{color:var(--text-muted);font-size:11px}
2416
+ #map-tooltip .tt-quality{font-size:11px;margin-top:2px}
2417
+ #map-empty{
2418
+ position:absolute;inset:0;display:flex;flex-direction:column;
2419
+ align-items:center;justify-content:center;gap:12px;color:var(--text-muted);
2420
+ }
2421
+ #map-empty p{font-size:14px}
2422
+
2423
+ /* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2424
+ TOPOLOGY VIEW
2425
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 */
2426
+ #topo-panel{
2427
+ width:220px;min-width:220px;height:100%;overflow:hidden;
2428
+ background:var(--bg-surface);border-right:1px solid var(--border);
2429
+ display:flex;flex-direction:column;
2430
+ }
2431
+ #topo-panel-header{
2432
+ padding:10px 12px 8px;border-bottom:1px solid var(--border);
2433
+ font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.6px;
2434
+ }
2435
+ #topo-search{
2436
+ width:calc(100% - 16px);margin:8px;padding:5px 8px;
2437
+ background:var(--bg-elevated);border:1px solid var(--border);border-radius:5px;
2438
+ color:var(--text);font-size:11px;font-family:inherit;outline:none;
2439
+ }
2440
+ #topo-search:focus{border-color:var(--accent)}
2441
+ #topo-list{flex:1;overflow-y:auto;padding-bottom:8px}
2442
+ .topo-item{
2443
+ padding:5px 12px;cursor:pointer;font-size:11px;
2444
+ display:flex;align-items:center;gap:6px;border-left:2px solid transparent;
2445
+ }
2446
+ .topo-item:hover{background:var(--bg-elevated)}
2447
+ .topo-item.active{background:var(--accent-dim);border-left-color:var(--accent)}
2448
+ .topo-dot{width:7px;height:7px;border-radius:2px;flex-shrink:0}
2449
+ .topo-name{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
2450
+ .topo-type{color:var(--text-dim);font-size:9px;flex-shrink:0}
2451
+
2452
+ #topo-graph{flex:1;height:100%;position:relative}
2453
+ #topo-graph svg{width:100%;height:100%}
2454
+ .hull{opacity:.12;stroke-width:1.5;stroke-opacity:.25}
2455
+ .hull-label{font-size:13px;font-weight:700;letter-spacing:1px;text-transform:uppercase;fill-opacity:.5;pointer-events:none}
2456
+ .link{stroke-opacity:.4}
2457
+ .link-label{font-size:8px;fill:var(--text-dim);pointer-events:none;opacity:0}
2458
+ .node-hex{stroke-width:1.8;cursor:pointer;transition:opacity .15s}
2459
+ .node-hex:hover{filter:brightness(1.3);stroke-width:3}
2460
+ .node-hex.selected{stroke-width:3.5;filter:brightness(1.5)}
2461
+ .node-label{font-size:10px;fill:var(--text);pointer-events:none;opacity:0}
2462
+
2463
+ #topo-sidebar{
2464
+ width:300px;min-width:300px;height:100%;overflow-y:auto;
2465
+ background:var(--bg-surface);border-left:1px solid var(--border);
2466
+ padding:16px;font-size:12px;line-height:1.6;
2467
+ }
2468
+ #topo-sidebar h2{margin:0 0 8px;font-size:14px;color:var(--accent)}
2469
+ #topo-sidebar .meta-table{width:100%;border-collapse:collapse}
2470
+ #topo-sidebar .meta-table td{padding:3px 6px;border-bottom:1px solid var(--border-dim);vertical-align:top}
2471
+ #topo-sidebar .meta-table td:first-child{color:var(--text-dim);white-space:nowrap;width:90px}
2472
+ #topo-sidebar .tag{display:inline-block;background:var(--bg-elevated);border-radius:3px;padding:1px 5px;margin:1px;font-size:10px}
2473
+ #topo-sidebar .conf-bar{height:5px;border-radius:3px;background:var(--bg-elevated);margin-top:3px}
2474
+ #topo-sidebar .conf-fill{height:100%;border-radius:3px}
2475
+ #topo-sidebar .edges-list{margin-top:12px}
2476
+ #topo-sidebar .edge-item{padding:4px 0;border-bottom:1px solid var(--border-dim);color:var(--text-dim);font-size:11px}
2477
+ #topo-sidebar .edge-item span{color:var(--text)}
2478
+ .hint{color:var(--text-dim);font-size:11px;margin-top:8px}
2479
+
2480
+ #topo-hud{
2481
+ position:absolute;top:10px;left:10px;background:rgba(15,23,42,.88);
2482
+ padding:10px 14px;border-radius:8px;font-size:12px;border:1px solid var(--border);pointer-events:none;
2483
+ }
2484
+ #topo-hud strong{color:var(--accent)}
2485
+ #topo-hud .stats{color:var(--text-dim)}
2486
+ #topo-hud .zoom-level{color:var(--text-dim);font-size:10px;margin-top:2px}
2487
+
2488
+ #topo-toolbar{position:absolute;top:10px;right:10px;display:flex;flex-wrap:wrap;gap:4px;pointer-events:auto;align-items:center}
2489
+ .filter-btn{
2490
+ background:rgba(15,23,42,.85);border:1px solid var(--border);border-radius:6px;
2491
+ color:var(--text);padding:4px 10px;font-size:11px;cursor:pointer;
2492
+ font-family:inherit;display:flex;align-items:center;gap:5px;
2493
+ }
2494
+ .filter-btn:hover{border-color:var(--text-dim)}
2495
+ .filter-btn.off{opacity:.35}
2496
+ .filter-dot{width:8px;height:8px;border-radius:2px;display:inline-block}
2497
+ .export-btn{
2498
+ background:rgba(15,23,42,.85);border:1px solid var(--border);border-radius:6px;
2499
+ color:var(--accent);padding:4px 12px;font-size:11px;cursor:pointer;font-family:inherit;
2500
+ }
2501
+ .export-btn:hover{border-color:var(--accent);background:var(--accent-dim)}
2910
2502
  </style>
2911
2503
  </head>
2912
- <body class="${meta.theme}">
2913
- <!-- Top bar -->
2914
- <div id="topbar">
2915
- <h1>Data Cartography Map</h1>
2916
- <div id="search-box">
2917
- <span style="color:#94a3b8;font-size:14px">&#8981;</span>
2918
- <input id="search-input" type="text" placeholder="Search assets..." aria-label="Search data assets"/>
2504
+ <body>
2505
+
2506
+ <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2507
+ TOPBAR
2508
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->
2509
+ <header id="topbar">
2510
+ <div class="tb-left">
2511
+ <svg class="brand-logo" width="32" height="32" viewBox="0 0 32 32" fill="none">
2512
+ <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"/>
2513
+ <circle cx="10" cy="16" r="2.8" fill="#60A5FA"/><circle cx="22" cy="10.5" r="2.2" fill="#38BDF8"/>
2514
+ <circle cx="22" cy="21.5" r="2.2" fill="#38BDF8"/>
2515
+ <line x1="12.5" y1="14.8" x2="19.8" y2="11.2" stroke="#93C5FD" stroke-width="1.2"/>
2516
+ <line x1="12.5" y1="17.2" x2="19.8" y2="20.8" stroke="#93C5FD" stroke-width="1.2"/>
2517
+ <line x1="22" y1="12.7" x2="22" y2="19.3" stroke="#93C5FD" stroke-width="1" stroke-dasharray="2 1.5"/>
2518
+ </svg>
2519
+ <span class="brand-name">datasynx</span>
2520
+ <span class="brand-sep"></span>
2521
+ <span class="brand-product">Cartography</span>
2919
2522
  </div>
2920
- <button id="theme-btn" title="Toggle dark/light mode" aria-label="Toggle theme">${meta.theme === "dark" ? "&#9788;" : "&#9790;"}</button>
2921
- </div>
2922
- <!-- SR summary -->
2923
- <div class="sr-only" role="status" aria-live="polite" id="sr-summary">
2924
- Data cartography map with ${assets.length} assets in ${clusters.length} clusters.
2925
- </div>
2926
- <!-- Main area -->
2927
- <div id="main">
2928
- <div id="canvas-wrap" role="application" aria-label="Data cartography hex map" tabindex="0">
2523
+ <div class="tb-center">
2524
+ <button class="tab-btn active" id="tab-map-btn" data-tab="map">Map</button>
2525
+ <button class="tab-btn" id="tab-topo-btn" data-tab="topo">Topology</button>
2526
+ </div>
2527
+ <div class="tb-right">
2528
+ <span class="tb-stats">${nodeCount} nodes &middot; ${edgeCount} edges</span>
2529
+ <div class="tb-search">
2530
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2531
+ <circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
2532
+ </svg>
2533
+ <input id="global-search" type="text" placeholder="Search..." autocomplete="off" spellcheck="false"/>
2534
+ </div>
2535
+ <a href="https://www.linkedin.com/company/datasynx-ai/" target="_blank" rel="noopener noreferrer"
2536
+ class="icon-btn" title="Datasynx on LinkedIn" aria-label="LinkedIn">
2537
+ <svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
2538
+ <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"/>
2539
+ </svg>
2540
+ </a>
2541
+ <button id="theme-btn" class="icon-btn" title="Toggle theme" aria-label="Toggle theme">
2542
+ ${theme === "dark" ? "&#9788;" : "&#9790;"}
2543
+ </button>
2544
+ </div>
2545
+ </header>
2546
+
2547
+ <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2548
+ MAP VIEW
2549
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->
2550
+ <div id="view-map" class="view active">
2551
+ <div id="map-wrap" tabindex="0" aria-label="Data cartography hex map">
2929
2552
  <canvas id="hexmap" aria-hidden="true"></canvas>
2930
- ${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>' : ""}
2553
+ ${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>' : ""}
2931
2554
  </div>
2932
- <div id="detail-panel" role="complementary" aria-label="Asset details">
2555
+ <div id="map-detail">
2933
2556
  <div class="panel-header">
2934
- <h3 id="dp-name">&mdash;</h3>
2935
- <button class="close-btn" id="dp-close" aria-label="Close panel">&#10005;</button>
2557
+ <h3 id="md-name">&mdash;</h3>
2558
+ <button class="close-btn" id="md-close" aria-label="Close">&#10005;</button>
2936
2559
  </div>
2937
- <div class="panel-body" id="dp-body"></div>
2560
+ <div class="panel-body" id="md-body"></div>
2938
2561
  </div>
2939
- </div>
2940
- <!-- Bottom-left toolbar -->
2941
- <div id="toolbar-left">
2942
- <button class="tb-btn active" id="btn-labels" title="Show labels" aria-pressed="true" aria-label="Toggle labels">&#127991;</button>
2943
- <button class="tb-btn" id="btn-quality" title="Quality layer" aria-pressed="false" aria-label="Toggle quality layer">&#128065;</button>
2944
- </div>
2945
- <!-- Bottom-right toolbar -->
2946
- <div id="toolbar-right">
2947
- <div id="zoom-controls">
2948
- <button class="zoom-btn" id="zoom-out" aria-label="Zoom out">&minus;</button>
2949
- <span id="zoom-pct">100%</span>
2950
- <button class="zoom-btn" id="zoom-in" aria-label="Zoom in">+</button>
2562
+ <div id="map-tb-left">
2563
+ <button class="tb-tool active" id="btn-labels" title="Toggle labels">&#127991;</button>
2564
+ <button class="tb-tool" id="btn-all-labels" title="Show all hex labels">Aa</button>
2565
+ <button class="tb-tool" id="btn-quality" title="Quality layer">&#128065;</button>
2566
+ <button class="tb-tool" id="btn-connect" title="Connection tool">&#128279;</button>
2951
2567
  </div>
2952
- <div id="detail-selector">
2953
- <button class="detail-btn" id="dl-1" aria-label="Detail level 1">1</button>
2954
- <button class="detail-btn active" id="dl-2" aria-label="Detail level 2">2</button>
2955
- <button class="detail-btn" id="dl-3" aria-label="Detail level 3">3</button>
2956
- <button class="detail-btn" id="dl-4" aria-label="Detail level 4">4</button>
2568
+ <div id="map-tb-right">
2569
+ <div class="map-zoom">
2570
+ <button class="zoom-btn" id="mz-out">&minus;</button>
2571
+ <span id="map-zoom-pct">100%</span>
2572
+ <button class="zoom-btn" id="mz-in">+</button>
2573
+ </div>
2957
2574
  </div>
2958
- <button id="connect-btn" title="Connection tool" aria-label="Toggle connection tool">&#128279;</button>
2575
+ <div id="map-connect-hint">Click two assets to create a connection</div>
2576
+ <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>
2959
2577
  </div>
2960
- <!-- Connection hint -->
2961
- <div id="connect-hint">Click two assets to create a connection</div>
2962
- <!-- Tooltip -->
2963
- <div id="tooltip" role="tooltip">
2964
- <div class="tt-name" id="tt-name"></div>
2965
- <div class="tt-domain" id="tt-domain"></div>
2966
- <div class="tt-quality" id="tt-quality"></div>
2578
+
2579
+ <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2580
+ TOPOLOGY VIEW
2581
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->
2582
+ <div id="view-topo" class="view">
2583
+ <div id="topo-panel">
2584
+ <div id="topo-panel-header">Nodes (${nodeCount})</div>
2585
+ <input id="topo-search" type="text" placeholder="Search nodes\u2026" autocomplete="off" spellcheck="false"/>
2586
+ <div id="topo-list"></div>
2587
+ </div>
2588
+ <div id="topo-graph">
2589
+ <div id="topo-hud">
2590
+ <strong>Topology</strong>&nbsp;
2591
+ <span class="stats">${nodeCount} nodes &middot; ${edgeCount} edges</span><br/>
2592
+ <span class="zoom-level">Scroll = zoom &middot; Drag = pan &middot; Click = details</span>
2593
+ </div>
2594
+ <div id="topo-toolbar"></div>
2595
+ <svg></svg>
2596
+ </div>
2597
+ <div id="topo-sidebar">
2598
+ <h2>Infrastructure Map</h2>
2599
+ <p class="hint">Click a node to view details.</p>
2600
+ </div>
2967
2601
  </div>
2968
2602
 
2969
2603
  <script>
2970
- (function() {
2971
- 'use strict';
2972
-
2973
- // \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
2974
- const MAP = ${dataJson};
2975
- const HEX_SIZE = ${HEX_SIZE2};
2976
- const IS_EMPTY = ${isEmpty};
2977
-
2978
- // Build asset index
2979
- const assetIndex = new Map();
2980
- const clusterByAsset = new Map();
2981
- for (const c of MAP.clusters) {
2982
- for (const aid of c.assetIds) {
2983
- clusterByAsset.set(aid, c);
2984
- }
2985
- }
2986
- for (const a of MAP.assets) {
2987
- assetIndex.set(a.id, a);
2988
- }
2989
-
2990
- // \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
2991
- const canvas = document.getElementById('hexmap');
2992
- const ctx = canvas.getContext('2d');
2993
- const wrap = document.getElementById('canvas-wrap');
2994
- let W = 0, H = 0;
2995
-
2996
- function resize() {
2997
- const dpr = window.devicePixelRatio || 1;
2998
- W = wrap.clientWidth; H = wrap.clientHeight;
2999
- canvas.width = W * dpr; canvas.height = H * dpr;
3000
- canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
3001
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
3002
- draw();
3003
- }
3004
- window.addEventListener('resize', resize);
3005
-
3006
- // \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
3007
- let vx = 0, vy = 0, scale = 1;
3008
- let detailLevel = 2, showLabels = true, showQuality = false;
3009
- let isDark = document.body.classList.contains('dark');
3010
- let connectMode = false, connectFirst = null;
3011
- let hoveredAssetId = null, selectedAssetId = null;
3012
- let searchQuery = '';
3013
- let localConnections = [...MAP.connections];
3014
-
3015
- // Flat-top hex math
3016
- function htp_x(q, r) { return HEX_SIZE * (3/2 * q); }
3017
- function htp_y(q, r) { return HEX_SIZE * (Math.sqrt(3)/2 * q + Math.sqrt(3) * r); }
3018
- function w2s(wx, wy) { return { x: wx*scale+vx, y: wy*scale+vy }; }
3019
- function s2w(sx, sy) { return { x: (sx-vx)/scale, y: (sy-vy)/scale }; }
3020
-
3021
- function fitToView() {
3022
- if (IS_EMPTY || MAP.assets.length === 0) { vx = 0; vy = 0; scale = 1; return; }
3023
- let mnx=Infinity,mny=Infinity,mxx=-Infinity,mxy=-Infinity;
3024
- for (const a of MAP.assets) {
3025
- const px=htp_x(a.q,a.r), py=htp_y(a.q,a.r);
3026
- if(px<mnx)mnx=px;if(py<mny)mny=py;if(px>mxx)mxx=px;if(py>mxy)mxy=py;
3027
- }
3028
- const pw=mxx-mnx+HEX_SIZE*4, ph=mxy-mny+HEX_SIZE*4;
3029
- scale = Math.min(W/pw, H/ph, 2) * 0.85;
3030
- vx = W/2 - ((mnx+mxx)/2)*scale;
3031
- vy = H/2 - ((mny+mxy)/2)*scale;
3032
- }
3033
-
3034
- // \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
3035
- function hexPath(cx, cy, r) {
3036
- ctx.beginPath();
3037
- for (let i=0;i<6;i++) {
3038
- const angle = Math.PI/180*(60*i);
3039
- const x=cx+r*Math.cos(angle), y=cy+r*Math.sin(angle);
3040
- i===0?ctx.moveTo(x,y):ctx.lineTo(x,y);
3041
- }
3042
- ctx.closePath();
3043
- }
3044
-
3045
- function shadeV(hex, amt) {
3046
- if(!hex||hex.length<7)return hex;
3047
- const n=parseInt(hex.replace('#',''),16);
3048
- const r=Math.min(255,(n>>16)+amt), g=Math.min(255,((n>>8)&0xff)+amt), b=Math.min(255,(n&0xff)+amt);
3049
- return '#'+r.toString(16).padStart(2,'0')+g.toString(16).padStart(2,'0')+b.toString(16).padStart(2,'0');
3050
- }
3051
-
3052
- function draw() {
3053
- ctx.clearRect(0,0,W,H);
3054
- ctx.fillStyle = isDark ? '#0f172a' : '#f8fafc';
3055
- ctx.fillRect(0,0,W,H);
3056
- if (IS_EMPTY) return;
3057
-
3058
- const size = HEX_SIZE * scale;
3059
- const matchedIds = getSearchMatches();
3060
- const hasSearch = searchQuery.length > 0;
3061
-
3062
- // Draw connections
3063
- ctx.save();
3064
- ctx.strokeStyle = isDark ? 'rgba(148,163,184,0.35)' : 'rgba(100,116,139,0.25)';
3065
- ctx.lineWidth = 1.5;
3066
- ctx.setLineDash([4,4]);
3067
- for (const conn of localConnections) {
3068
- const src = assetIndex.get(conn.sourceAssetId);
3069
- const tgt = assetIndex.get(conn.targetAssetId);
3070
- if (!src||!tgt) continue;
3071
- const sp=w2s(htp_x(src.q,src.r),htp_y(src.q,src.r));
3072
- const tp=w2s(htp_x(tgt.q,tgt.r),htp_y(tgt.q,tgt.r));
3073
- ctx.beginPath();ctx.moveTo(sp.x,sp.y);ctx.lineTo(tp.x,tp.y);ctx.stroke();
3074
- }
3075
- ctx.setLineDash([]);
3076
- ctx.restore();
3077
-
3078
- // Draw hexagons per cluster
3079
- for (const cluster of MAP.clusters) {
3080
- const baseColor = cluster.color;
3081
- const clusterAssets = cluster.assetIds.map(id=>assetIndex.get(id)).filter(Boolean);
3082
- const isClusterMatch = !hasSearch || clusterAssets.some(a => matchedIds.has(a.id));
3083
- const clusterDim = hasSearch && !isClusterMatch;
3084
-
3085
- for (let ai=0; ai<clusterAssets.length; ai++) {
3086
- const asset = clusterAssets[ai];
3087
- const wx=htp_x(asset.q,asset.r), wy=htp_y(asset.q,asset.r);
3088
- const s=w2s(wx,wy);
3089
- const cx=s.x, cy=s.y;
3090
-
3091
- // Frustum cull
3092
- if(cx+size<0||cx-size>W||cy+size<0||cy-size>H) continue;
3093
-
3094
- // Shade variation
3095
- const shade = ai%3===0?18:ai%3===1?8:0;
3096
- let fillColor = shadeV(baseColor, shade);
3097
-
3098
- // Quality overlay
3099
- if (showQuality && asset.qualityScore !== null && asset.qualityScore !== undefined) {
3100
- const q = asset.qualityScore;
3101
- if (q < 40) fillColor = '#ef4444';
3102
- else if (q < 70) fillColor = '#f97316';
3103
- }
2604
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2605
+ // SHARED STATE
2606
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2607
+ let isDark = document.documentElement.getAttribute('data-theme') === 'dark';
2608
+ let currentTab = 'map';
2609
+ let topoInited = false;
2610
+
2611
+ // \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
2612
+ document.getElementById('theme-btn').addEventListener('click', function() {
2613
+ isDark = !isDark;
2614
+ document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
2615
+ this.innerHTML = isDark ? '\\u2606' : '\\u263E';
2616
+ if (typeof drawMap === 'function') drawMap();
2617
+ });
2618
+
2619
+ // \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
2620
+ document.querySelectorAll('.tab-btn').forEach(function(btn) {
2621
+ btn.addEventListener('click', function() {
2622
+ var tab = this.getAttribute('data-tab');
2623
+ if (tab === currentTab) return;
2624
+ currentTab = tab;
2625
+ document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
2626
+ this.classList.add('active');
2627
+ document.querySelectorAll('.view').forEach(function(v) { v.classList.remove('active'); });
2628
+ document.getElementById('view-' + tab).classList.add('active');
2629
+ if (tab === 'topo' && !topoInited) { initTopology(); topoInited = true; }
2630
+ if (tab === 'map' && typeof drawMap === 'function') { resizeMap(); }
2631
+ });
2632
+ });
3104
2633
 
3105
- const alpha = clusterDim ? 0.18 : 1;
3106
- const isHovered = asset.id === hoveredAssetId;
3107
- const isSelected = asset.id === selectedAssetId;
3108
- const isConnectFirst = asset.id === connectFirst;
2634
+ // \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
2635
+ document.getElementById('global-search').addEventListener('input', function(e) {
2636
+ var q = e.target.value.trim();
2637
+ if (typeof setMapSearch === 'function') setMapSearch(q);
2638
+ if (typeof setTopoSearch === 'function') setTopoSearch(q);
2639
+ });
3109
2640
 
3110
- ctx.save();
3111
- ctx.globalAlpha = alpha;
3112
- hexPath(cx, cy, size*0.92);
2641
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2642
+ // MAP VIEW
2643
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2644
+ var MAP = ${mapJson};
2645
+ var MAP_HEX = ${HEX_SIZE2};
2646
+ var MAP_EMPTY = ${isEmpty};
2647
+
2648
+ var mapAssetIndex = new Map();
2649
+ var mapClusterByAsset = new Map();
2650
+ for (var ci = 0; ci < MAP.clusters.length; ci++) {
2651
+ var c = MAP.clusters[ci];
2652
+ for (var ai = 0; ai < c.assetIds.length; ai++) mapClusterByAsset.set(c.assetIds[ai], c);
2653
+ }
2654
+ for (var ni = 0; ni < MAP.assets.length; ni++) mapAssetIndex.set(MAP.assets[ni].id, MAP.assets[ni]);
2655
+
2656
+ var mapCanvas = document.getElementById('hexmap');
2657
+ var mapCtx = mapCanvas.getContext('2d');
2658
+ var mapWrap = document.getElementById('map-wrap');
2659
+ var mW = 0, mH = 0;
2660
+ var mvx = 0, mvy = 0, mScale = 1;
2661
+ var mDetailLevel = 2, mShowLabels = true, mShowQuality = false, mShowAllLabels = false;
2662
+ var mConnectMode = false, mConnectFirst = null;
2663
+ var mHoveredId = null, mSelectedId = null;
2664
+ var mSearchQuery = '';
2665
+ var mLocalConns = MAP.connections.slice();
2666
+
2667
+ function setMapSearch(q) { mSearchQuery = q; drawMap(); }
2668
+
2669
+ function resizeMap() {
2670
+ var dpr = window.devicePixelRatio || 1;
2671
+ mW = mapWrap.clientWidth; mH = mapWrap.clientHeight;
2672
+ mapCanvas.width = mW * dpr; mapCanvas.height = mH * dpr;
2673
+ mapCanvas.style.width = mW + 'px'; mapCanvas.style.height = mH + 'px';
2674
+ mapCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
2675
+ drawMap();
2676
+ }
2677
+ window.addEventListener('resize', function() { if (currentTab === 'map') resizeMap(); });
2678
+
2679
+ function mHtp_x(q, r) { return MAP_HEX * (1.5 * q); }
2680
+ function mHtp_y(q, r) { return MAP_HEX * (Math.sqrt(3) / 2 * q + Math.sqrt(3) * r); }
2681
+ function mW2s(wx, wy) { return { x: wx * mScale + mvx, y: wy * mScale + mvy }; }
2682
+ function mS2w(sx, sy) { return { x: (sx - mvx) / mScale, y: (sy - mvy) / mScale }; }
2683
+
2684
+ function mapFitToView() {
2685
+ if (MAP_EMPTY || MAP.assets.length === 0) { mvx = 0; mvy = 0; mScale = 1; return; }
2686
+ var mnx = Infinity, mny = Infinity, mxx = -Infinity, mxy = -Infinity;
2687
+ for (var i = 0; i < MAP.assets.length; i++) {
2688
+ var a = MAP.assets[i], px = mHtp_x(a.q, a.r), py = mHtp_y(a.q, a.r);
2689
+ if (px < mnx) mnx = px; if (py < mny) mny = py; if (px > mxx) mxx = px; if (py > mxy) mxy = py;
2690
+ }
2691
+ var pw = mxx - mnx + MAP_HEX * 4, ph = mxy - mny + MAP_HEX * 4;
2692
+ mScale = Math.min(mW / pw, mH / ph, 2) * 0.85;
2693
+ mvx = mW / 2 - ((mnx + mxx) / 2) * mScale;
2694
+ mvy = mH / 2 - ((mny + mxy) / 2) * mScale;
2695
+ }
2696
+
2697
+ function mHexPath(cx, cy, r) {
2698
+ mapCtx.beginPath();
2699
+ for (var i = 0; i < 6; i++) {
2700
+ var angle = Math.PI / 180 * (60 * i);
2701
+ var x = cx + r * Math.cos(angle), y = cy + r * Math.sin(angle);
2702
+ i === 0 ? mapCtx.moveTo(x, y) : mapCtx.lineTo(x, y);
2703
+ }
2704
+ mapCtx.closePath();
2705
+ }
2706
+
2707
+ function mShadeV(hex, amt) {
2708
+ if (!hex || hex.length < 7) return hex;
2709
+ var n = parseInt(hex.replace('#', ''), 16);
2710
+ var r = Math.min(255, (n >> 16) + amt), g = Math.min(255, ((n >> 8) & 0xff) + amt), b = Math.min(255, (n & 0xff) + amt);
2711
+ return '#' + r.toString(16).padStart(2, '0') + g.toString(16).padStart(2, '0') + b.toString(16).padStart(2, '0');
2712
+ }
2713
+
2714
+ function mGetSearchMatches() {
2715
+ if (!mSearchQuery) return new Set();
2716
+ var q = mSearchQuery.toLowerCase(), m = new Set();
2717
+ for (var i = 0; i < MAP.assets.length; i++) {
2718
+ var a = MAP.assets[i];
2719
+ if (a.name.toLowerCase().includes(q) || (a.domain && a.domain.toLowerCase().includes(q)) ||
2720
+ (a.subDomain && a.subDomain.toLowerCase().includes(q))) m.add(a.id);
2721
+ }
2722
+ return m;
2723
+ }
3113
2724
 
3114
- if (isDark && (isHovered||isSelected||isConnectFirst)) {
3115
- ctx.shadowColor = fillColor;
3116
- ctx.shadowBlur = isSelected ? 16 : 8;
2725
+ function mDrawPill(x, y, text, color, fontSize) {
2726
+ if (!text) return;
2727
+ mapCtx.save();
2728
+ mapCtx.font = '600 ' + fontSize + 'px -apple-system,sans-serif';
2729
+ var tw = mapCtx.measureText(text).width;
2730
+ var ph = fontSize + 8, pw = tw + 20;
2731
+ mapCtx.beginPath();
2732
+ if (mapCtx.roundRect) mapCtx.roundRect(x - pw / 2, y - ph / 2, pw, ph, ph / 2);
2733
+ else mapCtx.rect(x - pw / 2, y - ph / 2, pw, ph);
2734
+ mapCtx.fillStyle = isDark ? 'rgba(30,41,59,0.9)' : 'rgba(255,255,255,0.92)';
2735
+ mapCtx.shadowColor = 'rgba(0,0,0,0.15)'; mapCtx.shadowBlur = 6;
2736
+ mapCtx.fill(); mapCtx.shadowBlur = 0;
2737
+ mapCtx.fillStyle = isDark ? '#e2e8f0' : '#0f172a';
2738
+ mapCtx.textAlign = 'center'; mapCtx.textBaseline = 'middle';
2739
+ mapCtx.fillText(text, x, y);
2740
+ mapCtx.restore();
2741
+ }
2742
+
2743
+ function drawMap() {
2744
+ mapCtx.clearRect(0, 0, mW, mH);
2745
+ var bg = getComputedStyle(document.documentElement).getPropertyValue('--bg-base').trim();
2746
+ mapCtx.fillStyle = bg || (isDark ? '#0f172a' : '#f8fafc');
2747
+ mapCtx.fillRect(0, 0, mW, mH);
2748
+ if (MAP_EMPTY) return;
2749
+
2750
+ var size = MAP_HEX * mScale;
2751
+ var matchedIds = mGetSearchMatches();
2752
+ var hasSearch = mSearchQuery.length > 0;
2753
+
2754
+ // Connections
2755
+ mapCtx.save();
2756
+ mapCtx.strokeStyle = isDark ? 'rgba(148,163,184,0.35)' : 'rgba(100,116,139,0.25)';
2757
+ mapCtx.lineWidth = 1.5; mapCtx.setLineDash([4, 4]);
2758
+ for (var ci = 0; ci < mLocalConns.length; ci++) {
2759
+ var conn = mLocalConns[ci];
2760
+ var src = mapAssetIndex.get(conn.sourceAssetId), tgt = mapAssetIndex.get(conn.targetAssetId);
2761
+ if (!src || !tgt) continue;
2762
+ var sp = mW2s(mHtp_x(src.q, src.r), mHtp_y(src.q, src.r));
2763
+ var tp = mW2s(mHtp_x(tgt.q, tgt.r), mHtp_y(tgt.q, tgt.r));
2764
+ mapCtx.beginPath(); mapCtx.moveTo(sp.x, sp.y); mapCtx.lineTo(tp.x, tp.y); mapCtx.stroke();
2765
+ }
2766
+ mapCtx.setLineDash([]); mapCtx.restore();
2767
+
2768
+ // Hexagons per cluster
2769
+ for (var cli = 0; cli < MAP.clusters.length; cli++) {
2770
+ var cluster = MAP.clusters[cli];
2771
+ var baseColor = cluster.color;
2772
+ var clusterAssets = cluster.assetIds.map(function(id) { return mapAssetIndex.get(id); }).filter(Boolean);
2773
+ var isClusterMatch = !hasSearch || clusterAssets.some(function(a) { return matchedIds.has(a.id); });
2774
+ var clusterDim = hasSearch && !isClusterMatch;
2775
+
2776
+ for (var ai = 0; ai < clusterAssets.length; ai++) {
2777
+ var asset = clusterAssets[ai];
2778
+ var wx = mHtp_x(asset.q, asset.r), wy = mHtp_y(asset.q, asset.r);
2779
+ var s = mW2s(wx, wy), cx = s.x, cy = s.y;
2780
+ if (cx + size < 0 || cx - size > mW || cy + size < 0 || cy - size > mH) continue;
2781
+
2782
+ var shade = ai % 3 === 0 ? 18 : ai % 3 === 1 ? 8 : 0;
2783
+ var fillColor = mShadeV(baseColor, shade);
2784
+ if (mShowQuality && asset.qualityScore !== null && asset.qualityScore !== undefined) {
2785
+ if (asset.qualityScore < 40) fillColor = '#ef4444';
2786
+ else if (asset.qualityScore < 70) fillColor = '#f97316';
3117
2787
  }
3118
2788
 
3119
- ctx.fillStyle = fillColor;
3120
- ctx.fill();
3121
-
3122
- if (isSelected||isConnectFirst) {
3123
- ctx.strokeStyle = isConnectFirst ? '#f59e0b' : '#fff';
3124
- ctx.lineWidth = 2.5;
3125
- ctx.stroke();
3126
- } else if (isHovered) {
3127
- ctx.strokeStyle = isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.2)';
3128
- ctx.lineWidth = 1.5;
3129
- ctx.stroke();
3130
- } else {
3131
- ctx.strokeStyle = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.4)';
3132
- ctx.lineWidth = 1;
3133
- ctx.stroke();
3134
- }
3135
- ctx.restore();
3136
-
3137
- // Quality dot
3138
- if (showQuality && asset.qualityScore!==null && asset.qualityScore!==undefined && size>8) {
3139
- const q = asset.qualityScore;
3140
- if (q < 70) {
3141
- ctx.beginPath();
3142
- ctx.arc(cx+size*0.4, cy-size*0.4, Math.max(3,size*0.14), 0, Math.PI*2);
3143
- ctx.fillStyle = q<40?'#ef4444':'#f97316';
3144
- ctx.fill();
3145
- }
2789
+ var alpha = clusterDim ? 0.18 : 1;
2790
+ var isHov = asset.id === mHoveredId, isSel = asset.id === mSelectedId, isCF = asset.id === mConnectFirst;
2791
+
2792
+ mapCtx.save(); mapCtx.globalAlpha = alpha;
2793
+ mHexPath(cx, cy, size * 0.92);
2794
+ if (isDark && (isHov || isSel || isCF)) { mapCtx.shadowColor = fillColor; mapCtx.shadowBlur = isSel ? 16 : 8; }
2795
+ mapCtx.fillStyle = fillColor; mapCtx.fill();
2796
+ if (isSel || isCF) { mapCtx.strokeStyle = isCF ? '#f59e0b' : '#fff'; mapCtx.lineWidth = 2.5; mapCtx.stroke(); }
2797
+ else if (isHov) { mapCtx.strokeStyle = isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.2)'; mapCtx.lineWidth = 1.5; mapCtx.stroke(); }
2798
+ else { mapCtx.strokeStyle = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.4)'; mapCtx.lineWidth = 1; mapCtx.stroke(); }
2799
+ mapCtx.restore();
2800
+
2801
+ if (mShowQuality && asset.qualityScore !== null && asset.qualityScore !== undefined && size > 8 && asset.qualityScore < 70) {
2802
+ mapCtx.beginPath(); mapCtx.arc(cx + size * 0.4, cy - size * 0.4, Math.max(3, size * 0.14), 0, Math.PI * 2);
2803
+ mapCtx.fillStyle = asset.qualityScore < 40 ? '#ef4444' : '#f97316'; mapCtx.fill();
3146
2804
  }
3147
2805
 
3148
- // Asset labels (detail 4, or 3 at high zoom)
3149
- const showAssetLabel = showLabels && !clusterDim &&
3150
- ((detailLevel>=4)||(detailLevel===3 && scale>=0.8));
3151
- if (showAssetLabel && size>14) {
3152
- const label = asset.name.length>12 ? asset.name.substring(0,11)+'...' : asset.name;
3153
- ctx.save();
3154
- ctx.font = Math.max(8,Math.min(11,size*0.38))+'px -apple-system,sans-serif';
3155
- ctx.fillStyle = isDark ? 'rgba(255,255,255,0.85)' : 'rgba(255,255,255,0.9)';
3156
- ctx.textAlign='center';ctx.textBaseline='middle';
3157
- ctx.fillText(label, cx, cy);
3158
- ctx.restore();
2806
+ var showAssetLabel = mShowLabels && !clusterDim && (mShowAllLabels || (mDetailLevel >= 4) || (mDetailLevel === 3 && mScale >= 0.8));
2807
+ if (showAssetLabel && size > 6) {
2808
+ var label = asset.name.length > 12 ? asset.name.substring(0, 11) + '...' : asset.name;
2809
+ mapCtx.save();
2810
+ mapCtx.font = Math.max(8, Math.min(11, size * 0.38)) + 'px -apple-system,sans-serif';
2811
+ mapCtx.fillStyle = isDark ? 'rgba(255,255,255,0.85)' : 'rgba(255,255,255,0.9)';
2812
+ mapCtx.textAlign = 'center'; mapCtx.textBaseline = 'middle';
2813
+ mapCtx.fillText(label, cx, cy); mapCtx.restore();
3159
2814
  }
3160
2815
  }
3161
2816
  }
3162
2817
 
3163
- // Cluster labels (pill badges)
3164
- if (showLabels && detailLevel>=1) {
3165
- for (const cluster of MAP.clusters) {
3166
- if (cluster.assetIds.length===0) continue;
3167
- if (hasSearch && !cluster.assetIds.some(id=>matchedIds.has(id))) continue;
3168
- const s=w2s(cluster.centroid.x, cluster.centroid.y);
3169
- drawPill(s.x, s.y-size*1.2, cluster.label, cluster.color, 14);
2818
+ // Cluster labels
2819
+ if (mShowLabels && mDetailLevel >= 1) {
2820
+ for (var cli2 = 0; cli2 < MAP.clusters.length; cli2++) {
2821
+ var cl = MAP.clusters[cli2];
2822
+ if (cl.assetIds.length === 0) continue;
2823
+ if (hasSearch && !cl.assetIds.some(function(id) { return matchedIds.has(id); })) continue;
2824
+ var sc = mW2s(cl.centroid.x, cl.centroid.y);
2825
+ mDrawPill(sc.x, sc.y - size * 1.2, cl.label, cl.color, 14);
3170
2826
  }
3171
2827
  }
3172
2828
 
3173
- // Sub-domain labels (detail 2+)
3174
- if (showLabels && detailLevel>=2) {
3175
- const subGroups = new Map();
3176
- for (const a of MAP.assets) {
3177
- if (!a.subDomain) continue;
3178
- const key = a.domain+'|'+a.subDomain;
2829
+ // Sub-domain labels
2830
+ if (mShowLabels && mDetailLevel >= 2) {
2831
+ var subGroups = new Map();
2832
+ for (var si = 0; si < MAP.assets.length; si++) {
2833
+ var sa = MAP.assets[si];
2834
+ if (!sa.subDomain) continue;
2835
+ var key = sa.domain + '|' + sa.subDomain;
3179
2836
  if (!subGroups.has(key)) subGroups.set(key, []);
3180
- subGroups.get(key).push(a);
3181
- }
3182
- for (const [, group] of subGroups) {
3183
- let sx=0,sy=0;
3184
- for (const a of group) { sx+=htp_x(a.q,a.r); sy+=htp_y(a.q,a.r); }
3185
- const cx=sx/group.length, cy=sy/group.length;
3186
- const s = w2s(cx, cy);
3187
- drawPill(s.x, s.y+size*1.5, group[0].subDomain, '#64748b', 11);
2837
+ subGroups.get(key).push(sa);
3188
2838
  }
2839
+ subGroups.forEach(function(group) {
2840
+ var sx = 0, sy = 0;
2841
+ 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); }
2842
+ var cxs = sx / group.length, cys = sy / group.length;
2843
+ var spt = mW2s(cxs, cys);
2844
+ mDrawPill(spt.x, spt.y + size * 1.5, group[0].subDomain, '#64748b', 11);
2845
+ });
3189
2846
  }
3190
2847
  }
3191
2848
 
3192
- function drawPill(x, y, text, color, fontSize) {
3193
- if(!text) return;
3194
- ctx.save();
3195
- ctx.font = '600 '+fontSize+'px -apple-system,sans-serif';
3196
- const tw=ctx.measureText(text).width;
3197
- const ph=fontSize+8, pw=tw+20;
3198
- ctx.beginPath();
3199
- if (ctx.roundRect) ctx.roundRect(x-pw/2, y-ph/2, pw, ph, ph/2);
3200
- else { ctx.rect(x-pw/2, y-ph/2, pw, ph); }
3201
- ctx.fillStyle = isDark ? 'rgba(30,41,59,0.9)' : 'rgba(255,255,255,0.92)';
3202
- ctx.shadowColor='rgba(0,0,0,0.15)'; ctx.shadowBlur=6;
3203
- ctx.fill(); ctx.shadowBlur=0;
3204
- ctx.fillStyle = isDark ? '#e2e8f0' : '#0f172a';
3205
- ctx.textAlign='center'; ctx.textBaseline='middle';
3206
- ctx.fillText(text, x, y);
3207
- ctx.restore();
3208
- }
3209
-
3210
- // \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
3211
- function getAssetAt(sx, sy) {
3212
- const w=s2w(sx,sy);
3213
- for (const a of MAP.assets) {
3214
- const wx=htp_x(a.q,a.r), wy=htp_y(a.q,a.r);
3215
- const dx=Math.abs(w.x-wx), dy=Math.abs(w.y-wy);
3216
- if (dx>HEX_SIZE||dy>HEX_SIZE) continue;
3217
- if (dx*dx+dy*dy < HEX_SIZE*HEX_SIZE) return a;
2849
+ // \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
2850
+ function mGetAssetAt(sx, sy) {
2851
+ var w = mS2w(sx, sy);
2852
+ for (var i = 0; i < MAP.assets.length; i++) {
2853
+ var a = MAP.assets[i], wx = mHtp_x(a.q, a.r), wy = mHtp_y(a.q, a.r);
2854
+ var dx = Math.abs(w.x - wx), dy = Math.abs(w.y - wy);
2855
+ if (dx > MAP_HEX || dy > MAP_HEX) continue;
2856
+ if (dx * dx + dy * dy < MAP_HEX * MAP_HEX) return a;
3218
2857
  }
3219
2858
  return null;
3220
2859
  }
3221
2860
 
3222
- // \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
3223
- function getSearchMatches() {
3224
- if(!searchQuery) return new Set();
3225
- const q=searchQuery.toLowerCase();
3226
- const m=new Set();
3227
- for(const a of MAP.assets){
3228
- if(a.name.toLowerCase().includes(q)||(a.domain&&a.domain.toLowerCase().includes(q))||
3229
- (a.subDomain&&a.subDomain.toLowerCase().includes(q))) m.add(a.id);
3230
- }
3231
- return m;
3232
- }
3233
-
3234
- // \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
3235
- let dragging=false, lastMX=0, lastMY=0;
3236
-
3237
- wrap.addEventListener('mousedown', e=>{
3238
- if(e.button!==0)return;
3239
- dragging=true; lastMX=e.clientX; lastMY=e.clientY;
3240
- wrap.classList.add('dragging');
2861
+ // \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
2862
+ var mDragging = false, mLastMX = 0, mLastMY = 0;
2863
+ mapWrap.addEventListener('mousedown', function(e) {
2864
+ if (e.button !== 0) return;
2865
+ mDragging = true; mLastMX = e.clientX; mLastMY = e.clientY;
2866
+ mapWrap.classList.add('dragging');
3241
2867
  });
3242
- window.addEventListener('mouseup', ()=>{dragging=false;wrap.classList.remove('dragging');});
3243
- window.addEventListener('mousemove', e=>{
3244
- if(dragging){
3245
- vx+=e.clientX-lastMX; vy+=e.clientY-lastMY;
3246
- lastMX=e.clientX; lastMY=e.clientY; draw(); return;
3247
- }
3248
- const rect=wrap.getBoundingClientRect();
3249
- const sx=e.clientX-rect.left, sy=e.clientY-rect.top;
3250
- const asset=getAssetAt(sx,sy);
3251
- const newId=asset?asset.id:null;
3252
- if(newId!==hoveredAssetId){hoveredAssetId=newId;draw();}
3253
- const tt=document.getElementById('tooltip');
3254
- if(asset){
3255
- document.getElementById('tt-name').textContent=asset.name;
3256
- document.getElementById('tt-domain').textContent=asset.domain+(asset.subDomain?' > '+asset.subDomain:'');
3257
- document.getElementById('tt-quality').textContent=asset.qualityScore!==null?'Quality: '+asset.qualityScore+'/100':'';
3258
- tt.style.display='block';tt.style.left=(e.clientX+12)+'px';tt.style.top=(e.clientY-8)+'px';
3259
- } else { tt.style.display='none'; }
2868
+ window.addEventListener('mouseup', function() { mDragging = false; mapWrap.classList.remove('dragging'); });
2869
+ window.addEventListener('mousemove', function(e) {
2870
+ if (currentTab !== 'map') return;
2871
+ if (mDragging) {
2872
+ mvx += e.clientX - mLastMX; mvy += e.clientY - mLastMY;
2873
+ mLastMX = e.clientX; mLastMY = e.clientY; drawMap(); return;
2874
+ }
2875
+ var rect = mapWrap.getBoundingClientRect();
2876
+ var sx = e.clientX - rect.left, sy = e.clientY - rect.top;
2877
+ var asset = mGetAssetAt(sx, sy);
2878
+ var newId = asset ? asset.id : null;
2879
+ if (newId !== mHoveredId) { mHoveredId = newId; drawMap(); }
2880
+ var tt = document.getElementById('map-tooltip');
2881
+ if (asset) {
2882
+ document.getElementById('mtt-name').textContent = asset.name;
2883
+ document.getElementById('mtt-domain').textContent = asset.domain + (asset.subDomain ? ' > ' + asset.subDomain : '');
2884
+ document.getElementById('mtt-quality').textContent = asset.qualityScore !== null ? 'Quality: ' + asset.qualityScore + '/100' : '';
2885
+ tt.style.display = 'block'; tt.style.left = (e.clientX + 12) + 'px'; tt.style.top = (e.clientY - 8) + 'px';
2886
+ } else { tt.style.display = 'none'; }
3260
2887
  });
3261
2888
 
3262
- wrap.addEventListener('click', e=>{
3263
- const rect=wrap.getBoundingClientRect();
3264
- const sx=e.clientX-rect.left, sy=e.clientY-rect.top;
3265
- const asset=getAssetAt(sx,sy);
3266
- if(connectMode){
3267
- if(!asset) return;
3268
- if(!connectFirst){connectFirst=asset.id;draw();}
3269
- else if(connectFirst!==asset.id){
3270
- localConnections.push({id:crypto.randomUUID(),sourceAssetId:connectFirst,targetAssetId:asset.id,type:'connection'});
3271
- connectFirst=null;draw();
2889
+ mapWrap.addEventListener('click', function(e) {
2890
+ var rect = mapWrap.getBoundingClientRect();
2891
+ var sx = e.clientX - rect.left, sy = e.clientY - rect.top;
2892
+ var asset = mGetAssetAt(sx, sy);
2893
+ if (mConnectMode) {
2894
+ if (!asset) return;
2895
+ if (!mConnectFirst) { mConnectFirst = asset.id; drawMap(); }
2896
+ else if (mConnectFirst !== asset.id) {
2897
+ mLocalConns.push({ id: crypto.randomUUID(), sourceAssetId: mConnectFirst, targetAssetId: asset.id, type: 'connection' });
2898
+ mConnectFirst = null; drawMap();
3272
2899
  }
3273
2900
  return;
3274
2901
  }
3275
- if(asset){selectedAssetId=asset.id;showDetailPanel(asset);}
3276
- else{selectedAssetId=null;document.getElementById('detail-panel').classList.remove('open');}
3277
- draw();
2902
+ if (asset) { mSelectedId = asset.id; mShowDetail(asset); }
2903
+ else { mSelectedId = null; document.getElementById('map-detail').classList.remove('open'); }
2904
+ drawMap();
3278
2905
  });
3279
2906
 
3280
- // Touch
3281
- let lastTouches=[];
3282
- wrap.addEventListener('touchstart',e=>{lastTouches=[...e.touches];},{passive:true});
3283
- wrap.addEventListener('touchmove',e=>{
3284
- if(e.touches.length===1){
3285
- vx+=e.touches[0].clientX-lastTouches[0].clientX;
3286
- vy+=e.touches[0].clientY-lastTouches[0].clientY;draw();
3287
- } else if(e.touches.length===2){
3288
- const d0=Math.hypot(lastTouches[0].clientX-lastTouches[1].clientX,lastTouches[0].clientY-lastTouches[1].clientY);
3289
- const d1=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY);
3290
- const mx=(e.touches[0].clientX+e.touches[1].clientX)/2;
3291
- const my=(e.touches[0].clientY+e.touches[1].clientY)/2;
3292
- applyZoom(d1/d0,mx,my);
3293
- }
3294
- lastTouches=[...e.touches];
3295
- },{passive:true});
3296
-
3297
- wrap.addEventListener('wheel',e=>{
2907
+ mapWrap.addEventListener('wheel', function(e) {
3298
2908
  e.preventDefault();
3299
- const rect=wrap.getBoundingClientRect();
3300
- applyZoom(e.deltaY<0?1.12:1/1.12,e.clientX-rect.left,e.clientY-rect.top);
3301
- },{passive:false});
3302
-
3303
- function applyZoom(factor,sx,sy){
3304
- const ns=Math.max(0.05,Math.min(8,scale*factor));
3305
- const wx=(sx-vx)/scale,wy=(sy-vy)/scale;
3306
- scale=ns;vx=sx-wx*scale;vy=sy-wy*scale;
3307
- document.getElementById('zoom-pct').textContent=Math.round(scale*100)+'%';draw();
3308
- }
3309
- document.getElementById('zoom-in').addEventListener('click',()=>applyZoom(1.25,W/2,H/2));
3310
- document.getElementById('zoom-out').addEventListener('click',()=>applyZoom(1/1.25,W/2,H/2));
3311
-
3312
- // Keyboard
3313
- wrap.addEventListener('keydown',e=>{
3314
- const step=40;
3315
- if(e.key==='ArrowLeft'){vx+=step;draw();}
3316
- else if(e.key==='ArrowRight'){vx-=step;draw();}
3317
- else if(e.key==='ArrowUp'){vy+=step;draw();}
3318
- else if(e.key==='ArrowDown'){vy-=step;draw();}
3319
- else if(e.key==='+'||e.key==='=')applyZoom(1.2,W/2,H/2);
3320
- else if(e.key==='-')applyZoom(1/1.2,W/2,H/2);
3321
- else if(e.key==='Escape'){
3322
- selectedAssetId=null;document.getElementById('detail-panel').classList.remove('open');
3323
- if(connectMode)toggleConnect();draw();
3324
- }
2909
+ var rect = mapWrap.getBoundingClientRect();
2910
+ mApplyZoom(e.deltaY < 0 ? 1.12 : 1 / 1.12, e.clientX - rect.left, e.clientY - rect.top);
2911
+ }, { passive: false });
2912
+
2913
+ function mApplyZoom(factor, sx, sy) {
2914
+ var ns = Math.max(0.05, Math.min(8, mScale * factor));
2915
+ var wx = (sx - mvx) / mScale, wy = (sy - mvy) / mScale;
2916
+ mScale = ns; mvx = sx - wx * mScale; mvy = sy - wy * mScale;
2917
+ document.getElementById('map-zoom-pct').textContent = Math.round(mScale * 100) + '%';
2918
+ drawMap();
2919
+ }
2920
+
2921
+ document.getElementById('mz-in').addEventListener('click', function() { mApplyZoom(1.25, mW / 2, mH / 2); });
2922
+ document.getElementById('mz-out').addEventListener('click', function() { mApplyZoom(1 / 1.25, mW / 2, mH / 2); });
2923
+
2924
+ // \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
2925
+ function mEsc(s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
2926
+ function mRenderQ(s) {
2927
+ var c = s >= 70 ? '#22c55e' : s >= 40 ? '#f97316' : '#ef4444';
2928
+ return s + '/100 <div class="quality-bar"><div class="quality-fill" style="width:' + s + '%;background:' + c + '"></div></div>';
2929
+ }
2930
+ function mShowDetail(asset) {
2931
+ document.getElementById('md-name').textContent = asset.name;
2932
+ var body = document.getElementById('md-body');
2933
+ var rows = [['Domain', asset.domain], ['Sub-domain', asset.subDomain],
2934
+ ['Quality', asset.qualityScore !== null ? mRenderQ(asset.qualityScore) : null]
2935
+ ].concat(Object.entries(asset.metadata || {}).slice(0, 8).map(function(kv) { return [kv[0], String(kv[1])]; }))
2936
+ .filter(function(r) { return r[1] !== null && r[1] !== undefined && r[1] !== ''; });
2937
+ body.innerHTML = rows.map(function(r) {
2938
+ return '<div class="meta-row"><div class="meta-label">' + mEsc(String(r[0])) + '</div><div class="meta-value">' + r[1] + '</div></div>';
2939
+ }).join('');
2940
+ var related = mLocalConns.filter(function(cn) { return cn.sourceAssetId === asset.id || cn.targetAssetId === asset.id; });
2941
+ if (related.length > 0) {
2942
+ body.innerHTML += '<div class="meta-row"><div class="meta-label">Connections (' + related.length + ')</div><div>' +
2943
+ related.map(function(cn) {
2944
+ var oid = cn.sourceAssetId === asset.id ? cn.targetAssetId : cn.sourceAssetId;
2945
+ var o = mapAssetIndex.get(oid);
2946
+ return '<div class="meta-value" style="margin-top:4px;font-size:12px">' + (o ? mEsc(o.name) : oid) + '</div>';
2947
+ }).join('') + '</div></div>';
2948
+ }
2949
+ document.getElementById('map-detail').classList.add('open');
2950
+ }
2951
+ document.getElementById('md-close').addEventListener('click', function() {
2952
+ document.getElementById('map-detail').classList.remove('open'); mSelectedId = null; drawMap();
3325
2953
  });
3326
2954
 
3327
- // \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
3328
- function showDetailPanel(asset) {
3329
- document.getElementById('dp-name').textContent=asset.name;
3330
- const body=document.getElementById('dp-body');
3331
- const rows=[['Domain',asset.domain],['Sub-domain',asset.subDomain],
3332
- ['Quality Score',asset.qualityScore!==null?renderQuality(asset.qualityScore):null],
3333
- ...Object.entries(asset.metadata||{}).slice(0,8).map(([k,v])=>[k,String(v)])
3334
- ].filter(([,v])=>v!==null&&v!==undefined&&v!=='');
3335
- 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('');
3336
- const related=localConnections.filter(c=>c.sourceAssetId===asset.id||c.targetAssetId===asset.id);
3337
- if(related.length>0){
3338
- body.innerHTML+='<div class="meta-row"><div class="meta-label">Connections ('+related.length+')</div><div>'+
3339
- related.map(c=>{const oid=c.sourceAssetId===asset.id?c.targetAssetId:c.sourceAssetId;
3340
- 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>';
3341
- }
3342
- document.getElementById('detail-panel').classList.add('open');
3343
- }
3344
- function renderQuality(s){
3345
- const c=s>=70?'#22c55e':s>=40?'#f97316':'#ef4444';
3346
- return s+'/100 <div class="quality-bar"><div class="quality-fill" style="width:'+s+'%;background:'+c+'"></div></div>';
3347
- }
3348
- function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
3349
- document.getElementById('dp-close').addEventListener('click',()=>{
3350
- document.getElementById('detail-panel').classList.remove('open');selectedAssetId=null;draw();
2955
+ // \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
2956
+ document.getElementById('btn-labels').addEventListener('click', function() {
2957
+ mShowLabels = !mShowLabels; this.classList.toggle('active', mShowLabels); drawMap();
3351
2958
  });
3352
-
3353
- // \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
3354
- [1,2,3,4].forEach(n=>{
3355
- document.getElementById('dl-'+n).addEventListener('click',()=>{
3356
- detailLevel=n;document.querySelectorAll('.detail-btn').forEach(b=>b.classList.remove('active'));
3357
- document.getElementById('dl-'+n).classList.add('active');draw();
3358
- });
2959
+ document.getElementById('btn-all-labels').addEventListener('click', function() {
2960
+ mShowAllLabels = !mShowAllLabels; this.classList.toggle('active', mShowAllLabels);
2961
+ if (mShowAllLabels && !mShowLabels) { mShowLabels = true; document.getElementById('btn-labels').classList.add('active'); }
2962
+ drawMap();
3359
2963
  });
3360
- document.getElementById('btn-labels').addEventListener('click',()=>{
3361
- showLabels=!showLabels;document.getElementById('btn-labels').classList.toggle('active',showLabels);draw();
2964
+ document.getElementById('btn-quality').addEventListener('click', function() {
2965
+ mShowQuality = !mShowQuality; this.classList.toggle('active', mShowQuality); drawMap();
3362
2966
  });
3363
- document.getElementById('btn-quality').addEventListener('click',()=>{
3364
- showQuality=!showQuality;document.getElementById('btn-quality').classList.toggle('active',showQuality);draw();
2967
+ document.getElementById('btn-connect').addEventListener('click', function() {
2968
+ mConnectMode = !mConnectMode; mConnectFirst = null;
2969
+ this.classList.toggle('active', mConnectMode);
2970
+ mapWrap.classList.toggle('connecting', mConnectMode);
2971
+ document.getElementById('map-connect-hint').style.display = mConnectMode ? 'block' : 'none'; drawMap();
3365
2972
  });
3366
- function toggleConnect(){
3367
- connectMode=!connectMode;connectFirst=null;
3368
- document.getElementById('connect-btn').classList.toggle('active',connectMode);
3369
- wrap.classList.toggle('connecting',connectMode);
3370
- document.getElementById('connect-hint').style.display=connectMode?'block':'none';draw();
3371
- }
3372
- document.getElementById('connect-btn').addEventListener('click',toggleConnect);
3373
- document.getElementById('theme-btn').addEventListener('click',()=>{
3374
- isDark=!isDark;
3375
- document.body.classList.toggle('dark',isDark);document.body.classList.toggle('light',!isDark);
3376
- document.getElementById('theme-btn').innerHTML=isDark?'&#9788;':'&#9790;';draw();
2973
+
2974
+ // Map keyboard
2975
+ mapWrap.addEventListener('keydown', function(e) {
2976
+ if (e.key === 'ArrowLeft') { mvx += 40; drawMap(); }
2977
+ else if (e.key === 'ArrowRight') { mvx -= 40; drawMap(); }
2978
+ else if (e.key === 'ArrowUp') { mvy += 40; drawMap(); }
2979
+ else if (e.key === 'ArrowDown') { mvy -= 40; drawMap(); }
2980
+ else if (e.key === '+' || e.key === '=') mApplyZoom(1.2, mW / 2, mH / 2);
2981
+ else if (e.key === '-') mApplyZoom(1 / 1.2, mW / 2, mH / 2);
2982
+ else if (e.key === 'Escape') {
2983
+ mSelectedId = null; document.getElementById('map-detail').classList.remove('open');
2984
+ 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'); }
2985
+ drawMap();
2986
+ }
3377
2987
  });
3378
- document.getElementById('search-input').addEventListener('input',e=>{searchQuery=e.target.value.trim();draw();});
3379
2988
 
3380
- // \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
3381
- resize(); fitToView();
3382
- document.getElementById('zoom-pct').textContent=Math.round(scale*100)+'%';
3383
- draw();
3384
- })();
2989
+ // Map init
2990
+ resizeMap(); mapFitToView();
2991
+ document.getElementById('map-zoom-pct').textContent = Math.round(mScale * 100) + '%';
2992
+ drawMap();
2993
+
2994
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2995
+ // TOPOLOGY VIEW (lazy init)
2996
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2997
+ var TOPO = ${graphData};
2998
+
2999
+ var TYPE_COLORS = {
3000
+ host:'#4a9eff',database_server:'#ff6b6b',database:'#ff8c42',
3001
+ web_service:'#6bcb77',api_endpoint:'#4d96ff',cache_server:'#ffd93d',
3002
+ message_broker:'#c77dff',queue:'#e0aaff',topic:'#9d4edd',
3003
+ container:'#48cae4',pod:'#00b4d8',k8s_cluster:'#0077b6',
3004
+ config_file:'#adb5bd',saas_tool:'#c084fc',table:'#f97316',unknown:'#6c757d'
3005
+ };
3006
+ var LAYER_COLORS = { saas:'#c084fc',web:'#6bcb77',data:'#ff6b6b',messaging:'#c77dff',infra:'#4a9eff',config:'#adb5bd',other:'#6c757d' };
3007
+ var LAYER_NAMES = { saas:'SaaS Tools',web:'Web / API',data:'Data Layer',messaging:'Messaging',infra:'Infrastructure',config:'Config',other:'Other' };
3008
+
3009
+ var topoSelectedId = null;
3010
+
3011
+ function setTopoSearch(q) {
3012
+ var el = document.getElementById('topo-search');
3013
+ if (el) { el.value = q; buildTopoList(q); }
3014
+ }
3015
+
3016
+ function buildTopoList(filter) {
3017
+ var listEl = document.getElementById('topo-list');
3018
+ var q = (filter || '').toLowerCase();
3019
+ listEl.innerHTML = '';
3020
+ var sorted = TOPO.nodes.slice().sort(function(a, b) { return a.name.localeCompare(b.name); });
3021
+ for (var i = 0; i < sorted.length; i++) {
3022
+ var d = sorted[i];
3023
+ if (q && !d.name.toLowerCase().includes(q) && !d.type.includes(q) && !d.id.toLowerCase().includes(q)) continue;
3024
+ var item = document.createElement('div');
3025
+ item.className = 'topo-item' + (d.id === topoSelectedId ? ' active' : '');
3026
+ item.dataset.id = d.id;
3027
+ var color = TYPE_COLORS[d.type] || '#aaa';
3028
+ item.innerHTML = '<span class="topo-dot" style="background:' + color + '"></span>' +
3029
+ '<span class="topo-name" title="' + d.id + '">' + d.name + '</span>' +
3030
+ '<span class="topo-type">' + d.type.replace(/_/g, ' ') + '</span>';
3031
+ (function(dd) { item.onclick = function() { selectTopoNode(dd); focusTopoNode(dd); }; })(d);
3032
+ listEl.appendChild(item);
3033
+ }
3034
+ }
3035
+
3036
+ document.getElementById('topo-search').addEventListener('input', function(e) { buildTopoList(e.target.value); });
3037
+
3038
+ var topoSidebar = document.getElementById('topo-sidebar');
3039
+
3040
+ function selectTopoNode(d) {
3041
+ topoSelectedId = d.id;
3042
+ buildTopoList(document.getElementById('topo-search').value);
3043
+ showTopoNode(d);
3044
+ if (typeof d3 !== 'undefined') d3.selectAll('.node-hex').classed('selected', function(nd) { return nd.id === d.id; });
3045
+ }
3046
+
3047
+ function showTopoNode(d) {
3048
+ var c = TYPE_COLORS[d.type] || '#aaa';
3049
+ var confPct = Math.round(d.confidence * 100);
3050
+ var tags = (d.tags || []).map(function(t) { return '<span class="tag">' + t + '</span>'; }).join('');
3051
+ var metaRows = Object.entries(d.metadata || {})
3052
+ .filter(function(kv) { return kv[1] !== null && kv[1] !== undefined && String(kv[1]).length > 0; })
3053
+ .map(function(kv) { return '<tr><td>' + kv[0] + '</td><td>' + JSON.stringify(kv[1]) + '</td></tr>'; }).join('');
3054
+ var related = TOPO.links.filter(function(l) {
3055
+ return (l.source.id || l.source) === d.id || (l.target.id || l.target) === d.id;
3056
+ });
3057
+ var edgeItems = related.map(function(l) {
3058
+ var isOut = (l.source.id || l.source) === d.id;
3059
+ var other = isOut ? (l.target.id || l.target) : (l.source.id || l.source);
3060
+ return '<div class="edge-item">' + (isOut ? '\\u2192' : '\\u2190') + ' <span>' + other + '</span> <small>[' + l.relationship + ']</small></div>';
3061
+ }).join('');
3062
+
3063
+ topoSidebar.innerHTML =
3064
+ '<h2>' + d.name + '</h2>' +
3065
+ '<table class="meta-table">' +
3066
+ '<tr><td>ID</td><td style="font-size:10px;word-break:break-all">' + d.id + '</td></tr>' +
3067
+ '<tr><td>Type</td><td><span style="color:' + c + '">' + d.type + '</span></td></tr>' +
3068
+ '<tr><td>Layer</td><td>' + d.layer + '</td></tr>' +
3069
+ '<tr><td>Confidence</td><td>' + confPct + '% <div class="conf-bar"><div class="conf-fill" style="width:' + confPct + '%;background:' + c + '"></div></div></td></tr>' +
3070
+ '<tr><td>Via</td><td>' + (d.discoveredVia || '\\u2014') + '</td></tr>' +
3071
+ '<tr><td>Timestamp</td><td>' + (d.discoveredAt ? d.discoveredAt.substring(0, 19).replace('T', ' ') : '\\u2014') + '</td></tr>' +
3072
+ (tags ? '<tr><td>Tags</td><td>' + tags + '</td></tr>' : '') +
3073
+ metaRows + '</table>' +
3074
+ (related.length > 0 ? '<div class="edges-list"><strong>Connections (' + related.length + '):</strong>' + edgeItems + '</div>' : '') +
3075
+ '<div style="margin-top:14px"><button class="export-btn" style="width:100%" onclick="deleteTopoNode(\\'' + d.id.replace(/'/g, "\\\\'") + '\\')">Delete node</button></div>';
3076
+ }
3077
+
3078
+ function deleteTopoNode(id) {
3079
+ var idx = TOPO.nodes.findIndex(function(n) { return n.id === id; });
3080
+ if (idx === -1) return;
3081
+ TOPO.nodes.splice(idx, 1);
3082
+ TOPO.links = TOPO.links.filter(function(l) {
3083
+ return (l.source.id || l.source) !== id && (l.target.id || l.target) !== id;
3084
+ });
3085
+ topoSelectedId = null;
3086
+ topoSidebar.innerHTML = '<h2>Infrastructure Map</h2><p class="hint">Node deleted.</p>';
3087
+ if (typeof rebuildTopoGraph === 'function') rebuildTopoGraph();
3088
+ buildTopoList(document.getElementById('topo-search').value);
3089
+ }
3090
+
3091
+ function initTopology() {
3092
+ if (typeof d3 === 'undefined') return;
3093
+
3094
+ var svgEl = d3.select('#topo-graph svg');
3095
+ var graphDiv = document.getElementById('topo-graph');
3096
+ var gW = function() { return graphDiv.clientWidth; };
3097
+ var gH = function() { return graphDiv.clientHeight; };
3098
+ var g = svgEl.append('g');
3099
+
3100
+ svgEl.append('defs').append('marker')
3101
+ .attr('id', 'arrow').attr('viewBox', '0 0 10 6')
3102
+ .attr('refX', 10).attr('refY', 3)
3103
+ .attr('markerWidth', 8).attr('markerHeight', 6)
3104
+ .attr('orient', 'auto')
3105
+ .append('path').attr('d', 'M0,0 L10,3 L0,6 Z').attr('fill', '#555');
3106
+
3107
+ var currentZoom = 1;
3108
+ var zoomBehavior = d3.zoom().scaleExtent([0.08, 6]).on('zoom', function(e) {
3109
+ g.attr('transform', e.transform); currentZoom = e.transform.k; updateTopoLOD(currentZoom);
3110
+ });
3111
+ svgEl.call(zoomBehavior);
3112
+
3113
+ // Layer filters
3114
+ var layers = Array.from(new Set(TOPO.nodes.map(function(d) { return d.layer; })));
3115
+ var layerVisible = {};
3116
+ layers.forEach(function(l) { layerVisible[l] = true; });
3117
+
3118
+ var toolbarEl = document.getElementById('topo-toolbar');
3119
+ layers.forEach(function(layer) {
3120
+ var btn = document.createElement('button');
3121
+ btn.className = 'filter-btn';
3122
+ btn.innerHTML = '<span class="filter-dot" style="background:' + (LAYER_COLORS[layer] || '#666') + '"></span>' + (LAYER_NAMES[layer] || layer);
3123
+ btn.onclick = function() { layerVisible[layer] = !layerVisible[layer]; btn.classList.toggle('off', !layerVisible[layer]); updateTopoVisibility(); };
3124
+ toolbarEl.appendChild(btn);
3125
+ });
3126
+
3127
+ // JGF export button
3128
+ var jgfBtn = document.createElement('button');
3129
+ jgfBtn.className = 'export-btn'; jgfBtn.textContent = '\\u2193 JGF'; jgfBtn.title = 'Export JSON Graph Format';
3130
+ jgfBtn.onclick = function() {
3131
+ var jgf = { graph: { directed: true, type: 'cartography', label: 'Infrastructure Map',
3132
+ metadata: { exportedAt: new Date().toISOString() },
3133
+ 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 } }]; })),
3134
+ 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 } }; })
3135
+ }};
3136
+ var blob = new Blob([JSON.stringify(jgf, null, 2)], { type: 'application/json' });
3137
+ var url = URL.createObjectURL(blob);
3138
+ var a = document.createElement('a'); a.href = url; a.download = 'cartography-graph.jgf.json'; a.click();
3139
+ URL.revokeObjectURL(url);
3140
+ };
3141
+ toolbarEl.appendChild(jgfBtn);
3142
+
3143
+ // Hex helpers
3144
+ var T_HEX = { saas_tool: 16, host: 18, database_server: 18, k8s_cluster: 20, default: 14 };
3145
+ function tHexSize(d) { return T_HEX[d.type] || T_HEX.default; }
3146
+ function tHexPath(size) {
3147
+ var pts = [];
3148
+ for (var i = 0; i < 6; i++) {
3149
+ var angle = (Math.PI / 3) * i - Math.PI / 6;
3150
+ pts.push([size * Math.cos(angle), size * Math.sin(angle)]);
3151
+ }
3152
+ return 'M' + pts.map(function(p) { return p.join(','); }).join('L') + 'Z';
3153
+ }
3154
+
3155
+ // Cluster force
3156
+ function clusterForce(alpha) {
3157
+ var centroids = {}, counts = {};
3158
+ TOPO.nodes.forEach(function(d) {
3159
+ if (!centroids[d.layer]) { centroids[d.layer] = { x: 0, y: 0 }; counts[d.layer] = 0; }
3160
+ centroids[d.layer].x += d.x || 0; centroids[d.layer].y += d.y || 0; counts[d.layer]++;
3161
+ });
3162
+ for (var l in centroids) { centroids[l].x /= counts[l]; centroids[l].y /= counts[l]; }
3163
+ var strength = alpha * 0.15;
3164
+ TOPO.nodes.forEach(function(d) {
3165
+ var cn = centroids[d.layer];
3166
+ if (cn) { d.vx += (cn.x - d.x) * strength; d.vy += (cn.y - d.y) * strength; }
3167
+ });
3168
+ }
3169
+
3170
+ // Hulls
3171
+ var hullGroup = g.append('g').attr('class', 'hulls');
3172
+ var hullPaths = {}, hullLabels = {};
3173
+ layers.forEach(function(layer) {
3174
+ hullPaths[layer] = hullGroup.append('path').attr('class', 'hull')
3175
+ .attr('fill', LAYER_COLORS[layer] || '#666').attr('stroke', LAYER_COLORS[layer] || '#666');
3176
+ hullLabels[layer] = hullGroup.append('text').attr('class', 'hull-label')
3177
+ .attr('fill', LAYER_COLORS[layer] || '#666').text(LAYER_NAMES[layer] || layer);
3178
+ });
3179
+
3180
+ function updateHulls() {
3181
+ layers.forEach(function(layer) {
3182
+ if (!layerVisible[layer]) { hullPaths[layer].attr('d', null); hullLabels[layer].attr('x', -9999); return; }
3183
+ var pts = TOPO.nodes.filter(function(d) { return d.layer === layer && layerVisible[d.layer]; }).map(function(d) { return [d.x, d.y]; });
3184
+ if (pts.length < 3) {
3185
+ hullPaths[layer].attr('d', null);
3186
+ if (pts.length > 0) hullLabels[layer].attr('x', pts[0][0]).attr('y', pts[0][1] - 30);
3187
+ else hullLabels[layer].attr('x', -9999);
3188
+ return;
3189
+ }
3190
+ var hull = d3.polygonHull(pts);
3191
+ if (!hull) { hullPaths[layer].attr('d', null); return; }
3192
+ var cx = d3.mean(hull, function(p) { return p[0]; });
3193
+ var cy = d3.mean(hull, function(p) { return p[1]; });
3194
+ var padded = hull.map(function(p) {
3195
+ var dx = p[0] - cx, dy = p[1] - cy;
3196
+ var len = Math.sqrt(dx * dx + dy * dy) || 1;
3197
+ return [p[0] + dx / len * 40, p[1] + dy / len * 40];
3198
+ });
3199
+ hullPaths[layer].attr('d', 'M' + padded.join('L') + 'Z');
3200
+ hullLabels[layer].attr('x', cx).attr('y', cy - d3.max(hull, function(p) { return Math.abs(p[1] - cy); }) - 30);
3201
+ });
3202
+ }
3203
+
3204
+ // Graph
3205
+ var linkSel, linkLabelSel, nodeSel, nodeLabelSel, sim;
3206
+ var linkGroup = g.append('g');
3207
+ var nodeGroup = g.append('g');
3208
+
3209
+ function focusTopoNode(d) {
3210
+ if (!d.x || !d.y) return;
3211
+ var w = gW(), h = gH();
3212
+ svgEl.transition().duration(500).call(
3213
+ zoomBehavior.transform,
3214
+ d3.zoomIdentity.translate(w / 2, h / 2).scale(Math.min(3, currentZoom < 1 ? 1.5 : currentZoom)).translate(-d.x, -d.y)
3215
+ );
3216
+ }
3217
+ window.focusTopoNode = focusTopoNode;
3218
+
3219
+ function rebuildTopoGraph() {
3220
+ if (sim) sim.stop();
3221
+
3222
+ linkSel = linkGroup.selectAll('line').data(TOPO.links, function(d) { return (d.source.id || d.source) + '>' + (d.target.id || d.target); });
3223
+ linkSel.exit().remove();
3224
+ var linkEnter = linkSel.enter().append('line').attr('class', 'link');
3225
+ linkSel = linkEnter.merge(linkSel)
3226
+ .attr('stroke', function(d) { return d.confidence < 0.6 ? '#2a2e35' : '#3d434b'; })
3227
+ .attr('stroke-dasharray', function(d) { return d.confidence < 0.6 ? '4 3' : null; })
3228
+ .attr('stroke-width', function(d) { return d.confidence < 0.6 ? 0.8 : 1.2; })
3229
+ .attr('marker-end', 'url(#arrow)');
3230
+ linkSel.select('title').remove();
3231
+ linkSel.append('title').text(function(d) { return d.relationship + ' (' + Math.round(d.confidence * 100) + '%)\\n' + (d.evidence || ''); });
3232
+
3233
+ linkLabelSel = linkGroup.selectAll('text').data(TOPO.links, function(d) { return (d.source.id || d.source) + '>' + (d.target.id || d.target); });
3234
+ linkLabelSel.exit().remove();
3235
+ linkLabelSel = linkLabelSel.enter().append('text').attr('class', 'link-label').merge(linkLabelSel).text(function(d) { return d.relationship; });
3236
+
3237
+ nodeSel = nodeGroup.selectAll('g').data(TOPO.nodes, function(d) { return d.id; });
3238
+ nodeSel.exit().remove();
3239
+ var nodeEnter = nodeSel.enter().append('g')
3240
+ .call(d3.drag()
3241
+ .on('start', function(e, d) { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
3242
+ .on('drag', function(e, d) { d.fx = e.x; d.fy = e.y; })
3243
+ .on('end', function(e, d) { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; })
3244
+ )
3245
+ .on('click', function(e, d) { e.stopPropagation(); selectTopoNode(d); });
3246
+ nodeEnter.append('path').attr('class', 'node-hex');
3247
+ nodeEnter.append('title');
3248
+ nodeEnter.append('text').attr('class', 'node-label').attr('text-anchor', 'middle');
3249
+
3250
+ nodeSel = nodeEnter.merge(nodeSel);
3251
+ nodeSel.select('.node-hex')
3252
+ .attr('d', function(d) { return tHexPath(tHexSize(d)); })
3253
+ .attr('fill', function(d) { return TYPE_COLORS[d.type] || '#aaa'; })
3254
+ .attr('stroke', function(d) { var c = d3.color(TYPE_COLORS[d.type] || '#aaa'); return c ? c.brighter(0.8).formatHex() : '#ccc'; })
3255
+ .attr('fill-opacity', function(d) { return 0.6 + d.confidence * 0.4; })
3256
+ .classed('selected', function(d) { return d.id === topoSelectedId; });
3257
+ nodeSel.select('title').text(function(d) { return d.name + ' (' + d.type + ')\\nconf: ' + Math.round(d.confidence * 100) + '%'; });
3258
+ nodeLabelSel = nodeSel.select('.node-label')
3259
+ .attr('dy', function(d) { return tHexSize(d) + 13; })
3260
+ .text(function(d) { return d.name.length > 20 ? d.name.substring(0, 18) + '\\u2026' : d.name; });
3261
+
3262
+ sim = d3.forceSimulation(TOPO.nodes)
3263
+ .force('link', d3.forceLink(TOPO.links).id(function(d) { return d.id; }).distance(function(d) { return d.relationship === 'contains' ? 50 : 100; }).strength(0.4))
3264
+ .force('charge', d3.forceManyBody().strength(-280))
3265
+ .force('center', d3.forceCenter(gW() / 2, gH() / 2))
3266
+ .force('collision', d3.forceCollide().radius(function(d) { return tHexSize(d) + 10; }))
3267
+ .force('cluster', clusterForce)
3268
+ .on('tick', function() {
3269
+ updateHulls();
3270
+ linkSel.attr('x1', function(d) { return d.source.x; }).attr('y1', function(d) { return d.source.y; })
3271
+ .attr('x2', function(d) { return d.target.x; }).attr('y2', function(d) { return d.target.y; });
3272
+ linkLabelSel.attr('x', function(d) { return (d.source.x + d.target.x) / 2; })
3273
+ .attr('y', function(d) { return (d.source.y + d.target.y) / 2 - 4; });
3274
+ nodeSel.attr('transform', function(d) { return 'translate(' + d.x + ',' + d.y + ')'; });
3275
+ });
3276
+ }
3277
+ window.rebuildTopoGraph = rebuildTopoGraph;
3278
+
3279
+ function updateTopoLOD(k) {
3280
+ if (nodeLabelSel) nodeLabelSel.style('opacity', k > 0.5 ? Math.min(1, (k - 0.5) * 2) : 0);
3281
+ if (linkLabelSel) linkLabelSel.style('opacity', k > 1.2 ? Math.min(1, (k - 1.2) * 3) : 0);
3282
+ d3.selectAll('.hull-label').style('font-size', k < 0.4 ? '18px' : '13px');
3283
+ }
3284
+
3285
+ function updateTopoVisibility() {
3286
+ if (!nodeSel) return;
3287
+ nodeSel.style('display', function(d) { return layerVisible[d.layer] ? null : 'none'; });
3288
+ linkSel.style('display', function(d) {
3289
+ var s = TOPO.nodes.find(function(n) { return n.id === (d.source.id || d.source); });
3290
+ var t = TOPO.nodes.find(function(n) { return n.id === (d.target.id || d.target); });
3291
+ return (s && layerVisible[s.layer]) && (t && layerVisible[t.layer]) ? null : 'none';
3292
+ });
3293
+ linkLabelSel.style('display', function(d) {
3294
+ var s = TOPO.nodes.find(function(n) { return n.id === (d.source.id || d.source); });
3295
+ var t = TOPO.nodes.find(function(n) { return n.id === (d.target.id || d.target); });
3296
+ return (s && layerVisible[s.layer]) && (t && layerVisible[t.layer]) ? null : 'none';
3297
+ });
3298
+ }
3299
+
3300
+ rebuildTopoGraph();
3301
+ buildTopoList();
3302
+ updateTopoLOD(1);
3303
+
3304
+ svgEl.on('click', function() {
3305
+ topoSelectedId = null;
3306
+ d3.selectAll('.node-hex').classed('selected', false);
3307
+ buildTopoList(document.getElementById('topo-search').value);
3308
+ topoSidebar.innerHTML = '<h2>Infrastructure Map</h2><p class="hint">Click a node to view details.</p>';
3309
+ });
3310
+ }
3311
+
3312
+ // Init topology node list (non-D3 part)
3313
+ buildTopoList();
3385
3314
  </script>
3386
3315
  </body>
3387
3316
  </html>`;
3388
3317
  }
3389
- function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml", "html", "map", "sops"]) {
3318
+ function exportJGF(nodes, edges) {
3319
+ const jgf = {
3320
+ graph: {
3321
+ directed: true,
3322
+ type: "cartography",
3323
+ label: "Infrastructure Map",
3324
+ metadata: { exportedAt: (/* @__PURE__ */ new Date()).toISOString() },
3325
+ nodes: Object.fromEntries(nodes.map((n) => [n.id, {
3326
+ label: n.name,
3327
+ metadata: {
3328
+ type: n.type,
3329
+ layer: nodeLayer(n.type),
3330
+ confidence: n.confidence,
3331
+ discoveredVia: n.discoveredVia,
3332
+ discoveredAt: n.discoveredAt,
3333
+ tags: n.tags,
3334
+ metadata: n.metadata
3335
+ }
3336
+ }])),
3337
+ edges: edges.map((e) => ({
3338
+ source: e.sourceId,
3339
+ target: e.targetId,
3340
+ relation: e.relationship,
3341
+ metadata: { confidence: e.confidence, evidence: e.evidence }
3342
+ }))
3343
+ }
3344
+ };
3345
+ return JSON.stringify(jgf, null, 2);
3346
+ }
3347
+ function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml", "html", "map", "discovery", "sops"]) {
3390
3348
  mkdirSync2(outputDir, { recursive: true });
3391
3349
  mkdirSync2(join2(outputDir, "sops"), { recursive: true });
3392
3350
  mkdirSync2(join2(outputDir, "workflows"), { recursive: true });
3393
3351
  const nodes = db.getNodes(sessionId);
3394
3352
  const edges = db.getEdges(sessionId);
3353
+ const jgfPath = join2(outputDir, "cartography-graph.jgf.json");
3354
+ writeFileSync(jgfPath, exportJGF(nodes, edges));
3355
+ process.stderr.write("\u2713 cartography-graph.jgf.json\n");
3395
3356
  if (formats.includes("mermaid")) {
3396
3357
  writeFileSync(join2(outputDir, "topology.mermaid"), generateTopologyMermaid(nodes, edges));
3397
3358
  writeFileSync(join2(outputDir, "dependencies.mermaid"), generateDependencyMermaid(nodes, edges));
@@ -3405,13 +3366,9 @@ function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml
3405
3366
  writeFileSync(join2(outputDir, "catalog-info.yaml"), exportBackstageYAML(nodes, edges));
3406
3367
  process.stderr.write("\u2713 catalog-info.yaml\n");
3407
3368
  }
3408
- if (formats.includes("html")) {
3409
- writeFileSync(join2(outputDir, "topology.html"), exportHTML(nodes, edges));
3410
- process.stderr.write("\u2713 topology.html\n");
3411
- }
3412
- if (formats.includes("map")) {
3413
- writeFileSync(join2(outputDir, "cartography-map.html"), exportCartographyMap(nodes, edges));
3414
- process.stderr.write("\u2713 cartography-map.html\n");
3369
+ if (formats.includes("html") || formats.includes("map") || formats.includes("discovery")) {
3370
+ writeFileSync(join2(outputDir, "discovery.html"), exportDiscoveryApp(nodes, edges));
3371
+ process.stderr.write("\u2713 discovery.html\n");
3415
3372
  }
3416
3373
  if (formats.includes("sops")) {
3417
3374
  const sops = db.getSOPs(sessionId);
@@ -3477,8 +3434,8 @@ export {
3477
3434
  edgesToConnections,
3478
3435
  exportAll,
3479
3436
  exportBackstageYAML,
3480
- exportCartographyMap,
3481
- exportHTML,
3437
+ exportDiscoveryApp,
3438
+ exportJGF,
3482
3439
  exportJSON,
3483
3440
  exportSOPDashboard,
3484
3441
  exportSOPMarkdown,