@datasynx/agentic-ai-cartography 0.6.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,646 +2227,6 @@ 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;
2752
- const isEmpty = assets.length === 0;
2753
- const HEX_SIZE2 = 24;
2754
- const dataJson = JSON.stringify({
2755
- assets: assets.map((a) => ({
2756
- id: a.id,
2757
- name: a.name,
2758
- domain: a.domain,
2759
- subDomain: a.subDomain ?? null,
2760
- qualityScore: a.qualityScore ?? null,
2761
- metadata: a.metadata,
2762
- q: a.position.q,
2763
- r: a.position.r
2764
- })),
2765
- clusters: clusters.map((c) => ({
2766
- id: c.id,
2767
- label: c.label,
2768
- domain: c.domain,
2769
- color: c.color,
2770
- assetIds: c.assetIds,
2771
- centroid: c.centroid
2772
- })),
2773
- connections: connections.map((c) => ({
2774
- id: c.id,
2775
- sourceAssetId: c.sourceAssetId,
2776
- targetAssetId: c.targetAssetId,
2777
- type: c.type ?? "connection"
2778
- }))
2779
- });
2780
- return `<!DOCTYPE html>
2781
- <html lang="en">
2782
- <head>
2783
- <meta charset="UTF-8"/>
2784
- <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
2785
- <title>Data Cartography Map</title>
2786
- <style>
2787
- *{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"}}
2790
- #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"};
2811
- display:flex;flex-direction:column;transform:translateX(100%);
2812
- transition:transform .2s ease;z-index:5;flex-shrink:0;overflow-y:auto;
2813
- }
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;
2817
- }
2818
- #detail-panel .panel-header h3{font-size:14px;font-weight:600;flex:1;word-break:break-word}
2819
- #detail-panel .close-btn{
2820
- 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;
2837
- display:flex;align-items:center;justify-content:center;font-size:18px;
2838
- transition:all .15s;color:inherit;
2839
- }
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}
2848
- .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{
2903
- position:absolute;top:12px;left:50%;transform:translateX(-50%);
2904
- background:#fef3c7;border:1px solid #f59e0b;color:#92400e;
2905
- padding:6px 14px;border-radius:20px;font-size:12px;font-weight:500;
2906
- display:none;z-index:20;pointer-events:none;
2907
- }
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}
2910
- </style>
2911
- </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"/>
2919
- </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">
2929
- <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>' : ""}
2931
- </div>
2932
- <div id="detail-panel" role="complementary" aria-label="Asset details">
2933
- <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>
2936
- </div>
2937
- <div class="panel-body" id="dp-body"></div>
2938
- </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>
2951
- </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>
2957
- </div>
2958
- <button id="connect-btn" title="Connection tool" aria-label="Toggle connection tool">&#128279;</button>
2959
- </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>
2967
- </div>
2968
-
2969
- <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
- }
3104
-
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;
3109
-
3110
- ctx.save();
3111
- ctx.globalAlpha = alpha;
3112
- hexPath(cx, cy, size*0.92);
3113
-
3114
- if (isDark && (isHovered||isSelected||isConnectFirst)) {
3115
- ctx.shadowColor = fillColor;
3116
- ctx.shadowBlur = isSelected ? 16 : 8;
3117
- }
3118
-
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
- }
3146
- }
3147
-
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();
3159
- }
3160
- }
3161
- }
3162
-
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);
3170
- }
3171
- }
3172
-
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;
3179
- 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);
3188
- }
3189
- }
3190
- }
3191
-
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;
3218
- }
3219
- return null;
3220
- }
3221
-
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');
3241
- });
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'; }
3260
- });
3261
-
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();
3272
- }
3273
- return;
3274
- }
3275
- if(asset){selectedAssetId=asset.id;showDetailPanel(asset);}
3276
- else{selectedAssetId=null;document.getElementById('detail-panel').classList.remove('open');}
3277
- draw();
3278
- });
3279
-
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=>{
3298
- 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
- }
3325
- });
3326
-
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();
3351
- });
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
- });
3359
- });
3360
- document.getElementById('btn-labels').addEventListener('click',()=>{
3361
- showLabels=!showLabels;document.getElementById('btn-labels').classList.toggle('active',showLabels);draw();
3362
- });
3363
- document.getElementById('btn-quality').addEventListener('click',()=>{
3364
- showQuality=!showQuality;document.getElementById('btn-quality').classList.toggle('active',showQuality);draw();
3365
- });
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();
3377
- });
3378
- document.getElementById('search-input').addEventListener('input',e=>{searchQuery=e.target.value.trim();draw();});
3379
-
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
- })();
3385
- </script>
3386
- </body>
3387
- </html>`;
3388
- }
3389
2230
  function exportDiscoveryApp(nodes, edges, options) {
3390
2231
  const theme = options?.theme ?? "dark";
3391
2232
  const graphData = JSON.stringify({
@@ -3546,8 +2387,9 @@ body{display:flex;flex-direction:column;background:var(--bg-base);color:var(--te
3546
2387
  width:40px;height:40px;border-radius:10px;border:1px solid var(--border);
3547
2388
  background:var(--bg-surface);box-shadow:0 1px 4px rgba(0,0,0,.08);cursor:pointer;
3548
2389
  display:flex;align-items:center;justify-content:center;font-size:18px;
3549
- transition:all .15s;color:var(--text);
2390
+ transition:all .15s;color:var(--text);font-family:-apple-system,sans-serif;
3550
2391
  }
2392
+ #btn-all-labels{font-size:14px;font-weight:700;letter-spacing:-.02em}
3551
2393
  .tb-tool:hover{border-color:var(--text-muted)}
3552
2394
  .tb-tool.active{background:var(--accent-dim);border-color:var(--accent)}
3553
2395
  .map-zoom{display:flex;align-items:center;gap:6px}
@@ -3719,6 +2561,7 @@ body{display:flex;flex-direction:column;background:var(--bg-base);color:var(--te
3719
2561
  </div>
3720
2562
  <div id="map-tb-left">
3721
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>
3722
2565
  <button class="tb-tool" id="btn-quality" title="Quality layer">&#128065;</button>
3723
2566
  <button class="tb-tool" id="btn-connect" title="Connection tool">&#128279;</button>
3724
2567
  </div>
@@ -3815,7 +2658,7 @@ var mapCtx = mapCanvas.getContext('2d');
3815
2658
  var mapWrap = document.getElementById('map-wrap');
3816
2659
  var mW = 0, mH = 0;
3817
2660
  var mvx = 0, mvy = 0, mScale = 1;
3818
- var mDetailLevel = 2, mShowLabels = true, mShowQuality = false;
2661
+ var mDetailLevel = 2, mShowLabels = true, mShowQuality = false, mShowAllLabels = false;
3819
2662
  var mConnectMode = false, mConnectFirst = null;
3820
2663
  var mHoveredId = null, mSelectedId = null;
3821
2664
  var mSearchQuery = '';
@@ -3960,8 +2803,8 @@ function drawMap() {
3960
2803
  mapCtx.fillStyle = asset.qualityScore < 40 ? '#ef4444' : '#f97316'; mapCtx.fill();
3961
2804
  }
3962
2805
 
3963
- var showAssetLabel = mShowLabels && !clusterDim && ((mDetailLevel >= 4) || (mDetailLevel === 3 && mScale >= 0.8));
3964
- if (showAssetLabel && size > 14) {
2806
+ var showAssetLabel = mShowLabels && !clusterDim && (mShowAllLabels || (mDetailLevel >= 4) || (mDetailLevel === 3 && mScale >= 0.8));
2807
+ if (showAssetLabel && size > 6) {
3965
2808
  var label = asset.name.length > 12 ? asset.name.substring(0, 11) + '...' : asset.name;
3966
2809
  mapCtx.save();
3967
2810
  mapCtx.font = Math.max(8, Math.min(11, size * 0.38)) + 'px -apple-system,sans-serif';
@@ -4113,6 +2956,11 @@ document.getElementById('md-close').addEventListener('click', function() {
4113
2956
  document.getElementById('btn-labels').addEventListener('click', function() {
4114
2957
  mShowLabels = !mShowLabels; this.classList.toggle('active', mShowLabels); drawMap();
4115
2958
  });
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();
2963
+ });
4116
2964
  document.getElementById('btn-quality').addEventListener('click', function() {
4117
2965
  mShowQuality = !mShowQuality; this.classList.toggle('active', mShowQuality); drawMap();
4118
2966
  });
@@ -4467,12 +3315,44 @@ buildTopoList();
4467
3315
  </body>
4468
3316
  </html>`;
4469
3317
  }
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
+ }
4470
3347
  function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml", "html", "map", "discovery", "sops"]) {
4471
3348
  mkdirSync2(outputDir, { recursive: true });
4472
3349
  mkdirSync2(join2(outputDir, "sops"), { recursive: true });
4473
3350
  mkdirSync2(join2(outputDir, "workflows"), { recursive: true });
4474
3351
  const nodes = db.getNodes(sessionId);
4475
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");
4476
3356
  if (formats.includes("mermaid")) {
4477
3357
  writeFileSync(join2(outputDir, "topology.mermaid"), generateTopologyMermaid(nodes, edges));
4478
3358
  writeFileSync(join2(outputDir, "dependencies.mermaid"), generateDependencyMermaid(nodes, edges));
@@ -4486,15 +3366,7 @@ function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml
4486
3366
  writeFileSync(join2(outputDir, "catalog-info.yaml"), exportBackstageYAML(nodes, edges));
4487
3367
  process.stderr.write("\u2713 catalog-info.yaml\n");
4488
3368
  }
4489
- if (formats.includes("html")) {
4490
- writeFileSync(join2(outputDir, "topology.html"), exportHTML(nodes, edges));
4491
- process.stderr.write("\u2713 topology.html\n");
4492
- }
4493
- if (formats.includes("map")) {
4494
- writeFileSync(join2(outputDir, "cartography-map.html"), exportCartographyMap(nodes, edges));
4495
- process.stderr.write("\u2713 cartography-map.html\n");
4496
- }
4497
- if (formats.includes("discovery")) {
3369
+ if (formats.includes("html") || formats.includes("map") || formats.includes("discovery")) {
4498
3370
  writeFileSync(join2(outputDir, "discovery.html"), exportDiscoveryApp(nodes, edges));
4499
3371
  process.stderr.write("\u2713 discovery.html\n");
4500
3372
  }
@@ -4562,9 +3434,8 @@ export {
4562
3434
  edgesToConnections,
4563
3435
  exportAll,
4564
3436
  exportBackstageYAML,
4565
- exportCartographyMap,
4566
3437
  exportDiscoveryApp,
4567
- exportHTML,
3438
+ exportJGF,
4568
3439
  exportJSON,
4569
3440
  exportSOPDashboard,
4570
3441
  exportSOPMarkdown,