@datasynx/agentic-ai-cartography 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ DOMAIN_COLORS,
4
+ DOMAIN_PALETTE,
3
5
  EDGE_RELATIONSHIPS,
4
6
  NODE_TYPES,
5
7
  SOPStepSchema,
6
8
  defaultConfig
7
- } from "./chunk-7NRS3WP6.js";
9
+ } from "./chunk-A7FTULDM.js";
8
10
  import {
9
11
  scanAllBookmarks,
10
12
  scanAllHistory
@@ -1213,8 +1215,8 @@ import { join as join2 } from "path";
1213
1215
 
1214
1216
  // src/hex.ts
1215
1217
  function hexToPixel(q, r, size) {
1216
- const x = size * (Math.sqrt(3) * q + Math.sqrt(3) / 2 * r);
1217
- const y = size * (3 / 2 * r);
1218
+ const x = size * (3 / 2 * q);
1219
+ const y = size * (Math.sqrt(3) / 2 * q + Math.sqrt(3) * r);
1218
1220
  return { x, y };
1219
1221
  }
1220
1222
  var HEX_DIRECTIONS = [
@@ -1229,7 +1231,7 @@ function hexDistance(a, b) {
1229
1231
  return (Math.abs(a.q - b.q) + Math.abs(a.q + a.r - b.q - b.r) + Math.abs(a.r - b.r)) / 2;
1230
1232
  }
1231
1233
  function hexRing(center, radius) {
1232
- if (radius === 0) return [{ ...center }];
1234
+ if (radius === 0) return [{ q: center.q, r: center.r }];
1233
1235
  const results = [];
1234
1236
  let hex = {
1235
1237
  q: center.q + HEX_DIRECTIONS[4].q * radius,
@@ -1250,8 +1252,8 @@ function hexSpiral(center, count) {
1250
1252
  const positions = [];
1251
1253
  let ring = 0;
1252
1254
  while (positions.length < count) {
1253
- const ring_positions = hexRing(center, ring);
1254
- for (const pos of ring_positions) {
1255
+ const ringPositions = hexRing(center, ring);
1256
+ for (const pos of ringPositions) {
1255
1257
  if (positions.length >= count) break;
1256
1258
  positions.push(pos);
1257
1259
  }
@@ -1261,73 +1263,74 @@ function hexSpiral(center, count) {
1261
1263
  }
1262
1264
 
1263
1265
  // src/cluster.ts
1264
- var DOMAIN_PALETTE = [
1265
- "#1a2e5a",
1266
- // 0 deep navy
1267
- "#1e3a8a",
1268
- // 1 dark blue
1269
- "#1d4ed8",
1270
- // 2 medium blue
1271
- "#2563eb",
1272
- // 3 blue
1273
- "#3b82f6",
1274
- // 4 light blue
1275
- "#6366f1",
1276
- // 5 indigo
1277
- "#818cf8",
1278
- // 6 periwinkle
1279
- "#7c9fc3",
1280
- // 7 slate blue
1281
- "#0e7490",
1282
- // 8 dark teal
1283
- "#0891b2",
1284
- // 9 teal
1285
- "#06b6d4",
1286
- // 10 cyan
1287
- "#22d3ee",
1288
- // 11 light cyan
1289
- "#0d9488",
1290
- // 12 dark teal-green
1291
- "#14b8a6",
1292
- // 13 teal
1293
- "#2dd4bf",
1294
- // 14 light teal
1295
- "#5eead4"
1296
- // 15 pale teal
1297
- ];
1298
- function domainColor(domain, allDomains) {
1266
+ function assignColor(domain, allDomains) {
1267
+ if (DOMAIN_COLORS[domain]) return DOMAIN_COLORS[domain];
1299
1268
  const idx = allDomains.indexOf(domain);
1300
1269
  return DOMAIN_PALETTE[idx % DOMAIN_PALETTE.length];
1301
1270
  }
1302
- var HEX_SIZE = 24;
1271
+ function assignColors(domains) {
1272
+ const result = {};
1273
+ for (const d of domains) {
1274
+ result[d] = assignColor(d, domains);
1275
+ }
1276
+ return result;
1277
+ }
1278
+ function groupByDomain(assets) {
1279
+ const map = /* @__PURE__ */ new Map();
1280
+ for (const a of assets) {
1281
+ const d = a.domain || "Other";
1282
+ if (!map.has(d)) map.set(d, []);
1283
+ map.get(d).push(a);
1284
+ }
1285
+ return map;
1286
+ }
1303
1287
  var CLUSTER_GAP = 3;
1304
- function layoutClusters(domainSizes) {
1305
- const placements = [];
1288
+ function layoutClusters(groups, hexSize) {
1289
+ const allDomains = Array.from(groups.keys());
1290
+ const colors = assignColors(allDomains);
1291
+ const sorted = Array.from(groups.entries()).sort((a, b) => b[1].length - a[1].length);
1306
1292
  const occupied = /* @__PURE__ */ new Set();
1307
1293
  const key = (q, r) => `${q},${r}`;
1308
- for (const { domain, count } of domainSizes) {
1309
- let origin = { q: 0, r: 0 };
1310
- if (placements.length > 0) {
1311
- origin = findFreeOrigin(placements, occupied, count, CLUSTER_GAP);
1312
- }
1313
- const positions = hexSpiral(origin, count);
1314
- for (const p of positions) {
1315
- occupied.add(key(p.q, p.r));
1294
+ const clusters = [];
1295
+ const allAssets = [];
1296
+ for (const [domain, domainAssets] of sorted) {
1297
+ const origin = clusters.length === 0 ? { q: 0, r: 0 } : findFreeOrigin(occupied, domainAssets.length, CLUSTER_GAP);
1298
+ const positions = hexSpiral(origin, domainAssets.length);
1299
+ const assetIds = [];
1300
+ for (let i = 0; i < domainAssets.length; i++) {
1301
+ const asset = domainAssets[i];
1302
+ asset.position = positions[i];
1303
+ assetIds.push(asset.id);
1304
+ occupied.add(key(positions[i].q, positions[i].r));
1305
+ allAssets.push(asset);
1316
1306
  }
1317
- placements.push({ domain, positions, origin });
1307
+ const centroid = computeCentroid(positions, hexSize);
1308
+ clusters.push({
1309
+ id: `cluster:${domain}`,
1310
+ label: domain,
1311
+ domain,
1312
+ color: colors[domain],
1313
+ assetIds,
1314
+ centroid
1315
+ });
1318
1316
  }
1319
- return placements;
1317
+ return { clusters, assets: allAssets };
1320
1318
  }
1321
- function findFreeOrigin(existing, occupied, newCount, gap) {
1322
- const radius = estimateRadius(newCount);
1323
- for (let searchRing = 1; searchRing < 200; searchRing++) {
1324
- const candidates = hexSpiral({ q: 0, r: 0 }, searchRing * (radius * 2 + gap + 2));
1319
+ function findFreeOrigin(occupied, count, gap) {
1320
+ const key = (q, r) => `${q},${r}`;
1321
+ for (let searchRadius = 1; searchRadius < 100; searchRadius++) {
1322
+ const candidates = hexSpiral({ q: 0, r: 0 }, 1 + 6 * searchRadius * (searchRadius + 1) / 2);
1325
1323
  for (const candidate of candidates) {
1326
- const testPositions = hexSpiral(candidate, newCount);
1324
+ const testPositions = hexSpiral(candidate, count);
1327
1325
  let fits = true;
1328
1326
  for (const tp of testPositions) {
1329
- for (const ep of existing.flatMap((p) => p.positions)) {
1330
- if (hexDistance(tp, ep) < gap) {
1327
+ if (occupied.has(key(tp.q, tp.r))) {
1328
+ fits = false;
1329
+ break;
1330
+ }
1331
+ for (const oKey of occupied) {
1332
+ const [oq, or] = oKey.split(",").map(Number);
1333
+ if (hexDistance(tp, { q: oq, r: or }) < gap) {
1331
1334
  fits = false;
1332
1335
  break;
1333
1336
  }
@@ -1337,97 +1340,90 @@ function findFreeOrigin(existing, occupied, newCount, gap) {
1337
1340
  if (fits) return candidate;
1338
1341
  }
1339
1342
  }
1340
- return { q: existing.length * 20, r: 0 };
1343
+ return { q: occupied.size * 5, r: 0 };
1341
1344
  }
1342
- function estimateRadius(count) {
1343
- return Math.ceil(Math.sqrt(count));
1344
- }
1345
- function computeCentroid(positions, size) {
1345
+ function computeCentroid(positions, hexSize) {
1346
+ if (positions.length === 0) return { x: 0, y: 0 };
1346
1347
  let sx = 0, sy = 0;
1347
1348
  for (const { q, r } of positions) {
1348
- const { x, y } = hexToPixel(q, r, size);
1349
+ const { x, y } = hexToPixel(q, r, hexSize);
1349
1350
  sx += x;
1350
1351
  sy += y;
1351
1352
  }
1352
1353
  return { x: sx / positions.length, y: sy / positions.length };
1353
1354
  }
1354
- function groupBySubDomain(assets) {
1355
- const map = /* @__PURE__ */ new Map();
1356
- for (const a of assets) {
1357
- if (!a.subDomain) continue;
1358
- if (!map.has(a.subDomain)) map.set(a.subDomain, []);
1359
- map.get(a.subDomain).push(a.id);
1360
- }
1361
- return Array.from(map.entries()).map(([subDomain, assetIds]) => ({
1362
- subDomain,
1363
- assetIds,
1364
- centroid: { x: 0, y: 0 }
1365
- // filled in after position assignment
1366
- }));
1367
- }
1368
- function buildClusterLayout(nodes) {
1369
- const size = HEX_SIZE;
1370
- const byDomain = /* @__PURE__ */ new Map();
1371
- for (const n of nodes) {
1372
- const d = n.domain ?? "Other";
1373
- if (!byDomain.has(d)) byDomain.set(d, []);
1374
- byDomain.get(d).push(n);
1375
- }
1376
- const domainSizes = Array.from(byDomain.entries()).map(([domain, ns]) => ({ domain, count: ns.length })).sort((a, b) => b.count - a.count);
1377
- const allDomains = domainSizes.map((d) => d.domain);
1378
- const placements = layoutClusters(domainSizes);
1379
- const clusters = [];
1380
- const subClustersMap = /* @__PURE__ */ new Map();
1381
- for (const placement of placements) {
1382
- const { domain, positions } = placement;
1383
- const domainNodes = byDomain.get(domain) ?? [];
1384
- const color = domainColor(domain, allDomains);
1385
- const assets = domainNodes.map((n, i) => ({
1386
- id: n.id,
1387
- name: n.name,
1388
- domain: n.domain ?? "Other",
1389
- subDomain: n.subDomain,
1390
- qualityScore: n.qualityScore,
1391
- metadata: n.metadata ?? {},
1392
- position: positions[i] ?? { q: 0, r: 0 }
1393
- }));
1394
- const centroid = computeCentroid(positions.slice(0, assets.length), size);
1395
- const clusterId = `cluster:${domain}`;
1396
- clusters.push({
1397
- id: clusterId,
1398
- label: domain,
1399
- domain,
1400
- color,
1401
- assets,
1402
- centroid
1403
- });
1404
- const subs = groupBySubDomain(assets);
1405
- const assetById = new Map(assets.map((a) => [a.id, a]));
1406
- for (const sub of subs) {
1407
- const subPositions = sub.assetIds.map((id) => assetById.get(id)?.position).filter(Boolean);
1408
- if (subPositions.length > 0) {
1409
- sub.centroid = computeCentroid(subPositions, size);
1410
- }
1411
- }
1412
- if (subs.length > 0) subClustersMap.set(clusterId, subs);
1355
+
1356
+ // src/mapper.ts
1357
+ var TYPE_TO_DOMAIN = {
1358
+ database_server: "Data Layer",
1359
+ database: "Data Layer",
1360
+ table: "Data Layer",
1361
+ cache_server: "Data Layer",
1362
+ web_service: "Web / API",
1363
+ api_endpoint: "Web / API",
1364
+ message_broker: "Messaging",
1365
+ queue: "Messaging",
1366
+ topic: "Messaging",
1367
+ host: "Infrastructure",
1368
+ container: "Infrastructure",
1369
+ pod: "Infrastructure",
1370
+ k8s_cluster: "Infrastructure",
1371
+ config_file: "Infrastructure",
1372
+ saas_tool: "SaaS Tools"
1373
+ };
1374
+ function resolveDomain(node) {
1375
+ if (node.domain) return node.domain;
1376
+ const meta = node.metadata;
1377
+ if (typeof meta["category"] === "string" && meta["category"].length > 0) {
1378
+ return meta["category"];
1413
1379
  }
1414
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1415
- for (const cluster of clusters) {
1416
- for (const asset of cluster.assets) {
1417
- const { x, y } = hexToPixel(asset.position.q, asset.position.r, size);
1418
- if (x < minX) minX = x;
1419
- if (y < minY) minY = y;
1420
- if (x > maxX) maxX = x;
1421
- if (y > maxY) maxY = y;
1380
+ for (const tag of node.tags ?? []) {
1381
+ if (tag.length > 2 && tag[0] === tag[0].toUpperCase()) {
1382
+ return tag;
1422
1383
  }
1423
1384
  }
1424
- if (!isFinite(minX)) {
1425
- minX = 0;
1426
- minY = 0;
1427
- maxX = 0;
1428
- maxY = 0;
1385
+ return TYPE_TO_DOMAIN[node.type] ?? "Other";
1386
+ }
1387
+ function nodesToAssets(nodes) {
1388
+ return nodes.map((n) => ({
1389
+ id: n.id,
1390
+ name: n.name,
1391
+ domain: resolveDomain(n),
1392
+ subDomain: n.subDomain,
1393
+ qualityScore: n.qualityScore ?? Math.round(n.confidence * 100),
1394
+ metadata: n.metadata ?? {},
1395
+ position: { q: 0, r: 0 }
1396
+ // will be assigned by layoutClusters
1397
+ }));
1398
+ }
1399
+ function edgesToConnections(edges) {
1400
+ return edges.map((e) => ({
1401
+ id: e.id,
1402
+ sourceAssetId: e.sourceId,
1403
+ targetAssetId: e.targetId,
1404
+ type: e.relationship
1405
+ }));
1406
+ }
1407
+ var HEX_SIZE = 24;
1408
+ function buildMapData(nodes, edges, options) {
1409
+ const rawAssets = nodesToAssets(nodes);
1410
+ const connections = edgesToConnections(edges);
1411
+ if (rawAssets.length === 0) {
1412
+ return {
1413
+ assets: [],
1414
+ clusters: [],
1415
+ connections,
1416
+ meta: { exportedAt: (/* @__PURE__ */ new Date()).toISOString(), theme: options?.theme ?? "light" }
1417
+ };
1429
1418
  }
1430
- return { clusters, subClusters: subClustersMap, hexSize: size, bounds: { minX, minY, maxX, maxY } };
1419
+ const groups = groupByDomain(rawAssets);
1420
+ const { clusters, assets } = layoutClusters(groups, HEX_SIZE);
1421
+ return {
1422
+ assets,
1423
+ clusters,
1424
+ connections,
1425
+ meta: { exportedAt: (/* @__PURE__ */ new Date()).toISOString(), theme: options?.theme ?? "light" }
1426
+ };
1431
1427
  }
1432
1428
 
1433
1429
  // src/exporter.ts
@@ -2174,58 +2170,13 @@ function exportSOPMarkdown(sop) {
2174
2170
  }
2175
2171
  return lines.join("\n");
2176
2172
  }
2177
- function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml", "html", "hexmap", "sops"]) {
2178
- mkdirSync2(outputDir, { recursive: true });
2179
- mkdirSync2(join2(outputDir, "sops"), { recursive: true });
2180
- mkdirSync2(join2(outputDir, "workflows"), { recursive: true });
2181
- const nodes = db.getNodes(sessionId);
2182
- const edges = db.getEdges(sessionId);
2183
- if (formats.includes("mermaid")) {
2184
- writeFileSync(join2(outputDir, "topology.mermaid"), generateTopologyMermaid(nodes, edges));
2185
- writeFileSync(join2(outputDir, "dependencies.mermaid"), generateDependencyMermaid(nodes, edges));
2186
- process.stderr.write("\u2713 topology.mermaid, dependencies.mermaid\n");
2187
- }
2188
- if (formats.includes("json")) {
2189
- writeFileSync(join2(outputDir, "catalog.json"), exportJSON(db, sessionId));
2190
- process.stderr.write("\u2713 catalog.json\n");
2191
- }
2192
- if (formats.includes("yaml")) {
2193
- writeFileSync(join2(outputDir, "catalog-info.yaml"), exportBackstageYAML(nodes, edges));
2194
- process.stderr.write("\u2713 catalog-info.yaml\n");
2195
- }
2196
- if (formats.includes("html")) {
2197
- writeFileSync(join2(outputDir, "topology.html"), exportHTML(nodes, edges));
2198
- process.stderr.write("\u2713 topology.html\n");
2199
- }
2200
- if (formats.includes("sops")) {
2201
- const sops = db.getSOPs(sessionId);
2202
- for (const sop of sops) {
2203
- const filename = sop.title.toLowerCase().replace(/[^a-z0-9]+/g, "-") + ".md";
2204
- writeFileSync(join2(outputDir, "sops", filename), exportSOPMarkdown(sop));
2205
- const wfFilename = `workflow-${sop.workflowId.substring(0, 8)}.mermaid`;
2206
- writeFileSync(join2(outputDir, "workflows", wfFilename), generateWorkflowMermaid(sop));
2207
- }
2208
- if (sops.length > 0) {
2209
- process.stderr.write(`\u2713 ${sops.length} SOPs + workflow diagrams
2210
- `);
2211
- }
2212
- }
2213
- if (formats.includes("hexmap")) {
2214
- const connections = db.getConnections(sessionId);
2215
- writeFileSync(join2(outputDir, "hexmap.html"), exportHexMap(nodes, connections));
2216
- process.stderr.write("\u2713 hexmap.html\n");
2217
- }
2218
- }
2219
- function exportHexMap(nodes, connections) {
2220
- const layout = buildClusterLayout(nodes);
2221
- const { clusters, subClusters, hexSize, bounds } = layout;
2222
- const clustersJson = JSON.stringify(clusters.map((c) => ({
2223
- id: c.id,
2224
- label: c.label,
2225
- domain: c.domain,
2226
- color: c.color,
2227
- centroid: c.centroid,
2228
- assets: c.assets.map((a) => ({
2173
+ function exportCartographyMap(nodes, edges, options) {
2174
+ const mapData = buildMapData(nodes, edges, options);
2175
+ const { assets, clusters, connections, meta } = mapData;
2176
+ const isEmpty = assets.length === 0;
2177
+ const HEX_SIZE2 = 24;
2178
+ const dataJson = JSON.stringify({
2179
+ assets: assets.map((a) => ({
2229
2180
  id: a.id,
2230
2181
  name: a.name,
2231
2182
  domain: a.domain,
@@ -2234,24 +2185,22 @@ function exportHexMap(nodes, connections) {
2234
2185
  metadata: a.metadata,
2235
2186
  q: a.position.q,
2236
2187
  r: a.position.r
2188
+ })),
2189
+ clusters: clusters.map((c) => ({
2190
+ id: c.id,
2191
+ label: c.label,
2192
+ domain: c.domain,
2193
+ color: c.color,
2194
+ assetIds: c.assetIds,
2195
+ centroid: c.centroid
2196
+ })),
2197
+ connections: connections.map((c) => ({
2198
+ id: c.id,
2199
+ sourceAssetId: c.sourceAssetId,
2200
+ targetAssetId: c.targetAssetId,
2201
+ type: c.type ?? "connection"
2237
2202
  }))
2238
- })));
2239
- const subClustersJson = JSON.stringify(
2240
- Object.fromEntries(
2241
- Array.from(subClusters.entries()).map(([cid, subs]) => [
2242
- cid,
2243
- subs.map((s) => ({ subDomain: s.subDomain, assetIds: s.assetIds, centroid: s.centroid }))
2244
- ])
2245
- )
2246
- );
2247
- const connectionsJson = JSON.stringify(connections.map((c) => ({
2248
- id: c.id,
2249
- sourceAssetId: c.sourceAssetId,
2250
- targetAssetId: c.targetAssetId,
2251
- type: c.type ?? "connection"
2252
- })));
2253
- const hexSizeVal = hexSize;
2254
- const isEmpty = nodes.length === 0;
2203
+ });
2255
2204
  return `<!DOCTYPE html>
2256
2205
  <html lang="en">
2257
2206
  <head>
@@ -2261,28 +2210,20 @@ function exportHexMap(nodes, connections) {
2261
2210
  <style>
2262
2211
  *{box-sizing:border-box;margin:0;padding:0}
2263
2212
  html,body{width:100%;height:100%;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
2264
- body{display:flex;flex-direction:column;background:#f8fafc;color:#1e293b}
2213
+ body{display:flex;flex-direction:column;background:${meta.theme === "dark" ? "#0f172a" : "#f8fafc"};color:${meta.theme === "dark" ? "#e2e8f0" : "#1e293b"}}
2265
2214
  #topbar{
2266
2215
  height:48px;display:flex;align-items:center;gap:16px;padding:0 20px;
2267
- background:#fff;border-bottom:1px solid #e2e8f0;z-index:10;flex-shrink:0;
2268
- }
2269
- #topbar h1{font-size:15px;font-weight:600;color:#0f172a;letter-spacing:-0.01em}
2270
- #topbar .nav-items{display:flex;gap:4px;margin-left:auto}
2271
- #topbar .nav-item{
2272
- padding:5px 12px;border-radius:6px;font-size:13px;cursor:pointer;
2273
- color:#64748b;border:none;background:transparent;
2216
+ background:${meta.theme === "dark" ? "#1e293b" : "#fff"};border-bottom:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};z-index:10;flex-shrink:0;
2274
2217
  }
2275
- #topbar .nav-item:hover{background:#f1f5f9;color:#0f172a}
2276
- #topbar .nav-item.active{background:#eff6ff;color:#2563eb;font-weight:500}
2218
+ #topbar h1{font-size:15px;font-weight:600;letter-spacing:-0.01em}
2277
2219
  #search-box{
2278
- display:flex;align-items:center;gap:8px;background:#f1f5f9;
2279
- border-radius:8px;padding:5px 10px;margin-left:8px;
2220
+ display:flex;align-items:center;gap:8px;background:${meta.theme === "dark" ? "#334155" : "#f1f5f9"};
2221
+ border-radius:8px;padding:5px 10px;margin-left:auto;
2280
2222
  }
2281
2223
  #search-box input{
2282
- border:none;background:transparent;font-size:13px;outline:none;width:160px;color:#0f172a;
2224
+ border:none;background:transparent;font-size:13px;outline:none;width:180px;color:inherit;
2283
2225
  }
2284
2226
  #search-box input::placeholder{color:#94a3b8}
2285
- #search-icon{color:#94a3b8;font-size:14px}
2286
2227
  #main{flex:1;display:flex;overflow:hidden;position:relative}
2287
2228
  #canvas-wrap{flex:1;position:relative;overflow:hidden;cursor:grab}
2288
2229
  #canvas-wrap.dragging{cursor:grabbing}
@@ -2290,47 +2231,38 @@ body{display:flex;flex-direction:column;background:#f8fafc;color:#1e293b}
2290
2231
  canvas{display:block;width:100%;height:100%}
2291
2232
  /* Detail panel */
2292
2233
  #detail-panel{
2293
- width:280px;background:#fff;border-left:1px solid #e2e8f0;
2234
+ width:280px;background:${meta.theme === "dark" ? "#1e293b" : "#fff"};border-left:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};
2294
2235
  display:flex;flex-direction:column;transform:translateX(100%);
2295
2236
  transition:transform .2s ease;z-index:5;flex-shrink:0;overflow-y:auto;
2296
2237
  }
2297
2238
  #detail-panel.open{transform:translateX(0)}
2298
2239
  #detail-panel .panel-header{
2299
- padding:16px;border-bottom:1px solid #e2e8f0;display:flex;align-items:center;gap:10px;
2240
+ padding:16px;border-bottom:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};display:flex;align-items:center;gap:10px;
2300
2241
  }
2301
2242
  #detail-panel .panel-header h3{font-size:14px;font-weight:600;flex:1;word-break:break-word}
2302
2243
  #detail-panel .close-btn{
2303
2244
  width:24px;height:24px;border:none;background:transparent;cursor:pointer;
2304
2245
  color:#94a3b8;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:16px;
2305
2246
  }
2306
- #detail-panel .close-btn:hover{background:#f1f5f9;color:#0f172a}
2247
+ #detail-panel .close-btn:hover{background:${meta.theme === "dark" ? "#334155" : "#f1f5f9"}}
2307
2248
  #detail-panel .panel-body{padding:12px 16px;display:flex;flex-direction:column;gap:12px}
2308
2249
  #detail-panel .meta-row{display:flex;flex-direction:column;gap:3px}
2309
2250
  #detail-panel .meta-label{font-size:11px;font-weight:500;color:#94a3b8;text-transform:uppercase;letter-spacing:.05em}
2310
- #detail-panel .meta-value{font-size:13px;color:#1e293b;word-break:break-all}
2311
- #detail-panel .quality-bar{height:6px;border-radius:3px;background:#e2e8f0;margin-top:4px}
2251
+ #detail-panel .meta-value{font-size:13px;word-break:break-all}
2252
+ #detail-panel .quality-bar{height:6px;border-radius:3px;background:${meta.theme === "dark" ? "#334155" : "#e2e8f0"};margin-top:4px}
2312
2253
  #detail-panel .quality-fill{height:6px;border-radius:3px;transition:width .3s}
2313
- #detail-panel .badge{
2314
- display:inline-flex;align-items:center;gap:4px;padding:2px 8px;
2315
- border-radius:12px;font-size:11px;font-weight:500;
2316
- }
2317
2254
  /* Bottom-left toolbar */
2318
2255
  #toolbar-left{
2319
2256
  position:absolute;bottom:20px;left:20px;display:flex;gap:8px;z-index:10;
2320
2257
  }
2321
2258
  .tb-btn{
2322
- width:40px;height:40px;border-radius:10px;border:1px solid #e2e8f0;
2323
- background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.08);cursor:pointer;
2259
+ width:40px;height:40px;border-radius:10px;border:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};
2260
+ background:${meta.theme === "dark" ? "#1e293b" : "#fff"};box-shadow:0 1px 4px rgba(0,0,0,.08);cursor:pointer;
2324
2261
  display:flex;align-items:center;justify-content:center;font-size:18px;
2325
- transition:all .15s;position:relative;
2326
- }
2327
- .tb-btn:hover{border-color:#94a3b8;box-shadow:0 2px 8px rgba(0,0,0,.12)}
2328
- .tb-btn.active{background:#eff6ff;border-color:#3b82f6}
2329
- .tb-btn[title]:hover::after{
2330
- content:attr(title);position:absolute;bottom:calc(100% + 6px);left:50%;
2331
- transform:translateX(-50%);background:#1e293b;color:#fff;padding:4px 8px;
2332
- border-radius:5px;font-size:11px;white-space:nowrap;pointer-events:none;
2262
+ transition:all .15s;color:inherit;
2333
2263
  }
2264
+ .tb-btn:hover{border-color:#94a3b8}
2265
+ .tb-btn.active{background:${meta.theme === "dark" ? "#1e3a5f" : "#eff6ff"};border-color:#3b82f6}
2334
2266
  /* Bottom-right toolbar */
2335
2267
  #toolbar-right{
2336
2268
  position:absolute;bottom:20px;right:20px;display:flex;flex-direction:column;
@@ -2338,116 +2270,106 @@ canvas{display:block;width:100%;height:100%}
2338
2270
  }
2339
2271
  #zoom-controls{display:flex;align-items:center;gap:6px}
2340
2272
  .zoom-btn{
2341
- width:34px;height:34px;border-radius:8px;border:1px solid #e2e8f0;
2342
- background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.08);cursor:pointer;
2343
- font-size:18px;color:#1e293b;display:flex;align-items:center;justify-content:center;
2344
- }
2345
- .zoom-btn:hover{background:#f1f5f9}
2346
- #zoom-pct{
2347
- font-size:12px;font-weight:500;color:#64748b;min-width:38px;text-align:center;
2273
+ width:34px;height:34px;border-radius:8px;border:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};
2274
+ background:${meta.theme === "dark" ? "#1e293b" : "#fff"};cursor:pointer;
2275
+ font-size:18px;color:inherit;display:flex;align-items:center;justify-content:center;
2348
2276
  }
2277
+ .zoom-btn:hover{background:${meta.theme === "dark" ? "#334155" : "#f1f5f9"}}
2278
+ #zoom-pct{font-size:12px;font-weight:500;color:#64748b;min-width:38px;text-align:center}
2349
2279
  #detail-selector{display:flex;flex-direction:column;gap:4px}
2350
2280
  .detail-btn{
2351
- width:34px;height:34px;border-radius:8px;border:1px solid #e2e8f0;
2352
- background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.08);cursor:pointer;
2281
+ width:34px;height:34px;border-radius:8px;border:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};
2282
+ background:${meta.theme === "dark" ? "#1e293b" : "#fff"};cursor:pointer;
2353
2283
  font-size:12px;font-weight:600;color:#64748b;display:flex;align-items:center;justify-content:center;
2354
2284
  }
2355
- .detail-btn:hover{background:#f1f5f9;color:#0f172a}
2356
- .detail-btn.active{background:#eff6ff;border-color:#3b82f6;color:#2563eb}
2285
+ .detail-btn:hover{background:${meta.theme === "dark" ? "#334155" : "#f1f5f9"}}
2286
+ .detail-btn.active{background:${meta.theme === "dark" ? "#1e3a5f" : "#eff6ff"};border-color:#3b82f6;color:#2563eb}
2357
2287
  #connect-btn{
2358
- width:40px;height:40px;border-radius:10px;border:1px solid #e2e8f0;
2359
- background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.08);cursor:pointer;
2360
- font-size:18px;display:flex;align-items:center;justify-content:center;
2288
+ width:40px;height:40px;border-radius:10px;border:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};
2289
+ background:${meta.theme === "dark" ? "#1e293b" : "#fff"};cursor:pointer;
2290
+ font-size:18px;display:flex;align-items:center;justify-content:center;color:inherit;
2361
2291
  }
2362
2292
  #connect-btn.active{background:#fef3c7;border-color:#f59e0b}
2363
2293
  /* Tooltip */
2364
2294
  #tooltip{
2365
2295
  position:fixed;background:#1e293b;color:#fff;border-radius:8px;
2366
2296
  padding:8px 12px;font-size:12px;pointer-events:none;z-index:100;
2367
- display:none;max-width:200px;box-shadow:0 4px 12px rgba(0,0,0,.15);
2297
+ display:none;max-width:220px;box-shadow:0 4px 12px rgba(0,0,0,.15);
2368
2298
  }
2369
2299
  #tooltip .tt-name{font-weight:600;margin-bottom:2px}
2370
2300
  #tooltip .tt-domain{color:#94a3b8;font-size:11px}
2301
+ #tooltip .tt-quality{font-size:11px;margin-top:2px}
2371
2302
  /* Empty state */
2372
2303
  #empty-state{
2373
2304
  position:absolute;inset:0;display:flex;flex-direction:column;
2374
2305
  align-items:center;justify-content:center;gap:12px;color:#94a3b8;
2375
2306
  }
2376
- #empty-state .es-icon{font-size:48px}
2377
2307
  #empty-state p{font-size:14px}
2378
2308
  /* Theme toggle */
2379
2309
  #theme-btn{
2380
- width:40px;height:40px;border-radius:10px;border:1px solid #e2e8f0;
2381
- background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.08);cursor:pointer;
2382
- font-size:18px;display:flex;align-items:center;justify-content:center;
2310
+ width:40px;height:40px;border-radius:10px;border:1px solid ${meta.theme === "dark" ? "#334155" : "#e2e8f0"};
2311
+ background:${meta.theme === "dark" ? "#1e293b" : "#fff"};cursor:pointer;
2312
+ font-size:18px;display:flex;align-items:center;justify-content:center;color:inherit;
2383
2313
  }
2384
- /* Dark mode overrides */
2314
+ /* Dark mode overrides (toggled via JS) */
2385
2315
  body.dark{background:#0f172a;color:#e2e8f0}
2386
2316
  body.dark #topbar{background:#1e293b;border-color:#334155}
2387
- body.dark #topbar h1{color:#f1f5f9}
2388
- body.dark #topbar .nav-item{color:#94a3b8}
2389
- body.dark #topbar .nav-item:hover{background:#334155;color:#f1f5f9}
2390
2317
  body.dark #search-box{background:#334155}
2391
- body.dark #search-box input{color:#f1f5f9}
2392
2318
  body.dark #detail-panel{background:#1e293b;border-color:#334155}
2393
- body.dark #detail-panel .panel-header{border-color:#334155}
2394
- body.dark #detail-panel .meta-value{color:#e2e8f0}
2395
2319
  body.dark .tb-btn,body.dark .zoom-btn,body.dark .detail-btn,body.dark #connect-btn,body.dark #theme-btn{
2396
2320
  background:#1e293b;border-color:#334155;color:#e2e8f0;
2397
2321
  }
2398
- body.dark .tb-btn:hover,body.dark .zoom-btn:hover,body.dark .detail-btn:hover{background:#334155}
2399
- body.dark #zoom-pct{color:#94a3b8}
2400
- /* Connection mode indicator */
2322
+ /* Light mode overrides */
2323
+ body.light{background:#f8fafc;color:#1e293b}
2324
+ body.light #topbar{background:#fff;border-color:#e2e8f0}
2325
+ /* Connection hint */
2401
2326
  #connect-hint{
2402
2327
  position:absolute;top:12px;left:50%;transform:translateX(-50%);
2403
2328
  background:#fef3c7;border:1px solid #f59e0b;color:#92400e;
2404
2329
  padding:6px 14px;border-radius:20px;font-size:12px;font-weight:500;
2405
2330
  display:none;z-index:20;pointer-events:none;
2406
2331
  }
2332
+ /* Screen reader only */
2333
+ .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}
2407
2334
  </style>
2408
2335
  </head>
2409
- <body>
2336
+ <body class="${meta.theme}">
2410
2337
  <!-- Top bar -->
2411
2338
  <div id="topbar">
2412
- <h1>\u{1F5FA} Data Cartography Map</h1>
2413
- <div class="nav-items">
2414
- <button class="nav-item active">Data Product Map</button>
2415
- <button class="nav-item">Raw Data Map</button>
2416
- <button class="nav-item">Analysis</button>
2417
- </div>
2339
+ <h1>Data Cartography Map</h1>
2418
2340
  <div id="search-box">
2419
- <span id="search-icon">\u2315</span>
2420
- <input id="search-input" type="text" placeholder="Search assets\u2026" aria-label="Search data assets"/>
2341
+ <span style="color:#94a3b8;font-size:14px">&#8981;</span>
2342
+ <input id="search-input" type="text" placeholder="Search assets..." aria-label="Search data assets"/>
2421
2343
  </div>
2422
- <button id="theme-btn" title="Toggle dark/light mode" aria-label="Toggle theme">\u{1F319}</button>
2344
+ <button id="theme-btn" title="Toggle dark/light mode" aria-label="Toggle theme">${meta.theme === "dark" ? "&#9788;" : "&#9790;"}</button>
2345
+ </div>
2346
+ <!-- SR summary -->
2347
+ <div class="sr-only" role="status" aria-live="polite" id="sr-summary">
2348
+ Data cartography map with ${assets.length} assets in ${clusters.length} clusters.
2423
2349
  </div>
2424
-
2425
2350
  <!-- Main area -->
2426
2351
  <div id="main">
2427
2352
  <div id="canvas-wrap" role="application" aria-label="Data cartography hex map" tabindex="0">
2428
2353
  <canvas id="hexmap" aria-hidden="true"></canvas>
2429
- ${isEmpty ? '<div id="empty-state"><div class="es-icon">\u{1F5FA}</div><p>No data assets available</p><p style="font-size:12px">Run <code>datasynx-cartography discover</code> to populate the map</p></div>' : ""}
2354
+ ${isEmpty ? '<div id="empty-state"><p style="font-size:48px">&#128506;</p><p>No data assets available</p><p style="font-size:12px">Run <code>datasynx-cartography discover</code> to populate the map</p></div>' : ""}
2430
2355
  </div>
2431
2356
  <div id="detail-panel" role="complementary" aria-label="Asset details">
2432
2357
  <div class="panel-header">
2433
- <h3 id="dp-name">\u2014</h3>
2434
- <button class="close-btn" id="dp-close" aria-label="Close panel">\u2715</button>
2358
+ <h3 id="dp-name">&mdash;</h3>
2359
+ <button class="close-btn" id="dp-close" aria-label="Close panel">&#10005;</button>
2435
2360
  </div>
2436
2361
  <div class="panel-body" id="dp-body"></div>
2437
2362
  </div>
2438
2363
  </div>
2439
-
2440
2364
  <!-- Bottom-left toolbar -->
2441
2365
  <div id="toolbar-left">
2442
- <button class="tb-btn active" id="btn-org" title="Organization view" aria-pressed="true" aria-label="Organization view">\u{1F3E2}</button>
2443
- <button class="tb-btn active" id="btn-labels" title="Show labels" aria-pressed="true" aria-label="Toggle labels">\u{1F3F7}</button>
2444
- <button class="tb-btn" id="btn-quality" title="Quality layer" aria-pressed="false" aria-label="Toggle quality layer">\u{1F441}</button>
2366
+ <button class="tb-btn active" id="btn-labels" title="Show labels" aria-pressed="true" aria-label="Toggle labels">&#127991;</button>
2367
+ <button class="tb-btn" id="btn-quality" title="Quality layer" aria-pressed="false" aria-label="Toggle quality layer">&#128065;</button>
2445
2368
  </div>
2446
-
2447
2369
  <!-- Bottom-right toolbar -->
2448
2370
  <div id="toolbar-right">
2449
2371
  <div id="zoom-controls">
2450
- <button class="zoom-btn" id="zoom-out" aria-label="Zoom out">\u2212</button>
2372
+ <button class="zoom-btn" id="zoom-out" aria-label="Zoom out">&minus;</button>
2451
2373
  <span id="zoom-pct">100%</span>
2452
2374
  <button class="zoom-btn" id="zoom-in" aria-label="Zoom in">+</button>
2453
2375
  </div>
@@ -2457,16 +2379,15 @@ body.dark #zoom-pct{color:#94a3b8}
2457
2379
  <button class="detail-btn" id="dl-3" aria-label="Detail level 3">3</button>
2458
2380
  <button class="detail-btn" id="dl-4" aria-label="Detail level 4">4</button>
2459
2381
  </div>
2460
- <button id="connect-btn" title="Connection tool" aria-label="Toggle connection tool">\u{1F517}</button>
2382
+ <button id="connect-btn" title="Connection tool" aria-label="Toggle connection tool">&#128279;</button>
2461
2383
  </div>
2462
-
2463
- <!-- Connection mode hint -->
2384
+ <!-- Connection hint -->
2464
2385
  <div id="connect-hint">Click two assets to create a connection</div>
2465
-
2466
- <!-- Hover tooltip -->
2386
+ <!-- Tooltip -->
2467
2387
  <div id="tooltip" role="tooltip">
2468
2388
  <div class="tt-name" id="tt-name"></div>
2469
2389
  <div class="tt-domain" id="tt-domain"></div>
2390
+ <div class="tt-quality" id="tt-quality"></div>
2470
2391
  </div>
2471
2392
 
2472
2393
  <script>
@@ -2474,26 +2395,28 @@ body.dark #zoom-pct{color:#94a3b8}
2474
2395
  'use strict';
2475
2396
 
2476
2397
  // \u2500\u2500 Data \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2477
- const CLUSTERS = ${clustersJson};
2478
- const SUB_CLUSTERS = ${subClustersJson};
2479
- const CONNECTIONS = ${connectionsJson};
2480
- const HEX_SIZE = ${hexSizeVal};
2398
+ const MAP = ${dataJson};
2399
+ const HEX_SIZE = ${HEX_SIZE2};
2481
2400
  const IS_EMPTY = ${isEmpty};
2482
2401
 
2483
- // Build flat asset index
2402
+ // Build asset index
2484
2403
  const assetIndex = new Map();
2485
- for (const c of CLUSTERS) {
2486
- for (const a of c.assets) {
2487
- assetIndex.set(a.id, { ...a, clusterColor: c.color, clusterId: c.id });
2404
+ const clusterByAsset = new Map();
2405
+ for (const c of MAP.clusters) {
2406
+ for (const aid of c.assetIds) {
2407
+ clusterByAsset.set(aid, c);
2488
2408
  }
2489
2409
  }
2410
+ for (const a of MAP.assets) {
2411
+ assetIndex.set(a.id, a);
2412
+ }
2490
2413
 
2491
- // \u2500\u2500 Canvas 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\u2500
2414
+ // \u2500\u2500 Canvas \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2492
2415
  const canvas = document.getElementById('hexmap');
2493
2416
  const ctx = canvas.getContext('2d');
2494
2417
  const wrap = document.getElementById('canvas-wrap');
2495
-
2496
2418
  let W = 0, H = 0;
2419
+
2497
2420
  function resize() {
2498
2421
  const dpr = window.devicePixelRatio || 1;
2499
2422
  W = wrap.clientWidth; H = wrap.clientHeight;
@@ -2504,140 +2427,123 @@ function resize() {
2504
2427
  }
2505
2428
  window.addEventListener('resize', resize);
2506
2429
 
2507
- // \u2500\u2500 Viewport 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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2430
+ // \u2500\u2500 Viewport \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2508
2431
  let vx = 0, vy = 0, scale = 1;
2509
- let detailLevel = 2;
2510
- let showLabels = true;
2511
- let showQuality = false;
2512
- let showOrg = true;
2513
- let isDark = false;
2514
- let connectMode = false;
2515
- let connectFirst = null;
2516
- let hoveredAssetId = null;
2517
- let selectedAssetId = null;
2432
+ let detailLevel = 2, showLabels = true, showQuality = false;
2433
+ let isDark = document.body.classList.contains('dark');
2434
+ let connectMode = false, connectFirst = null;
2435
+ let hoveredAssetId = null, selectedAssetId = null;
2518
2436
  let searchQuery = '';
2519
- let localConnections = [...CONNECTIONS];
2437
+ let localConnections = [...MAP.connections];
2438
+
2439
+ // Flat-top hex math
2440
+ function htp_x(q, r) { return HEX_SIZE * (3/2 * q); }
2441
+ function htp_y(q, r) { return HEX_SIZE * (Math.sqrt(3)/2 * q + Math.sqrt(3) * r); }
2442
+ function w2s(wx, wy) { return { x: wx*scale+vx, y: wy*scale+vy }; }
2443
+ function s2w(sx, sy) { return { x: (sx-vx)/scale, y: (sy-vy)/scale }; }
2520
2444
 
2521
2445
  function fitToView() {
2522
- if (IS_EMPTY || CLUSTERS.length === 0) { vx = 0; vy = 0; scale = 1; return; }
2523
- let minX=Infinity, minY=Infinity, maxX=-Infinity, maxY=-Infinity;
2524
- for (const c of CLUSTERS) for (const a of c.assets) {
2525
- const px = hexToPixelX(a.q, a.r);
2526
- const py = hexToPixelY(a.q, a.r);
2527
- if(px<minX)minX=px; if(py<minY)minY=py;
2528
- if(px>maxX)maxX=px; if(py>maxY)maxY=py;
2446
+ if (IS_EMPTY || MAP.assets.length === 0) { vx = 0; vy = 0; scale = 1; return; }
2447
+ let mnx=Infinity,mny=Infinity,mxx=-Infinity,mxy=-Infinity;
2448
+ for (const a of MAP.assets) {
2449
+ const px=htp_x(a.q,a.r), py=htp_y(a.q,a.r);
2450
+ if(px<mnx)mnx=px;if(py<mny)mny=py;if(px>mxx)mxx=px;if(py>mxy)mxy=py;
2529
2451
  }
2530
- const pw = maxX-minX+HEX_SIZE*4, ph = maxY-minY+HEX_SIZE*4;
2531
- scale = Math.min(W/pw, H/ph, 1) * 0.85;
2532
- vx = W/2 - ((minX+maxX)/2)*scale;
2533
- vy = H/2 - ((minY+maxY)/2)*scale;
2534
- }
2535
-
2536
- // \u2500\u2500 Hex math (inline for self-contained HTML) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2537
- function hexToPixelX(q, r) { return HEX_SIZE * (Math.sqrt(3)*q + Math.sqrt(3)/2*r); }
2538
- function hexToPixelY(q, r) { return HEX_SIZE * (3/2*r); }
2539
-
2540
- function worldToScreen(wx, wy) {
2541
- return { x: wx*scale+vx, y: wy*scale+vy };
2542
- }
2543
- function screenToWorld(sx, sy) {
2544
- return { x: (sx-vx)/scale, y: (sy-vy)/scale };
2452
+ const pw=mxx-mnx+HEX_SIZE*4, ph=mxy-mny+HEX_SIZE*4;
2453
+ scale = Math.min(W/pw, H/ph, 2) * 0.85;
2454
+ vx = W/2 - ((mnx+mxx)/2)*scale;
2455
+ vy = H/2 - ((mny+mxy)/2)*scale;
2545
2456
  }
2546
2457
 
2547
2458
  // \u2500\u2500 Drawing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2548
2459
  function hexPath(cx, cy, r) {
2549
2460
  ctx.beginPath();
2550
2461
  for (let i=0;i<6;i++) {
2551
- const angle = Math.PI/180*(60*i-30);
2552
- const x = cx+r*Math.cos(angle), y = cy+r*Math.sin(angle);
2553
- i===0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y);
2462
+ const angle = Math.PI/180*(60*i);
2463
+ const x=cx+r*Math.cos(angle), y=cy+r*Math.sin(angle);
2464
+ i===0?ctx.moveTo(x,y):ctx.lineTo(x,y);
2554
2465
  }
2555
2466
  ctx.closePath();
2556
2467
  }
2557
2468
 
2558
- function draw() {
2559
- ctx.clearRect(0, 0, W, H);
2469
+ function shadeV(hex, amt) {
2470
+ if(!hex||hex.length<7)return hex;
2471
+ const n=parseInt(hex.replace('#',''),16);
2472
+ const r=Math.min(255,(n>>16)+amt), g=Math.min(255,((n>>8)&0xff)+amt), b=Math.min(255,(n&0xff)+amt);
2473
+ return '#'+r.toString(16).padStart(2,'0')+g.toString(16).padStart(2,'0')+b.toString(16).padStart(2,'0');
2474
+ }
2560
2475
 
2561
- // Background
2476
+ function draw() {
2477
+ ctx.clearRect(0,0,W,H);
2562
2478
  ctx.fillStyle = isDark ? '#0f172a' : '#f8fafc';
2563
- ctx.fillRect(0, 0, W, H);
2564
-
2479
+ ctx.fillRect(0,0,W,H);
2565
2480
  if (IS_EMPTY) return;
2566
2481
 
2567
2482
  const size = HEX_SIZE * scale;
2568
2483
  const matchedIds = getSearchMatches();
2569
2484
  const hasSearch = searchQuery.length > 0;
2570
2485
 
2571
- // \u2500\u2500 Draw connections (edges) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2486
+ // Draw connections
2572
2487
  ctx.save();
2573
- ctx.strokeStyle = isDark ? 'rgba(148,163,184,0.4)' : 'rgba(100,116,139,0.3)';
2488
+ ctx.strokeStyle = isDark ? 'rgba(148,163,184,0.35)' : 'rgba(100,116,139,0.25)';
2574
2489
  ctx.lineWidth = 1.5;
2575
2490
  ctx.setLineDash([4,4]);
2576
2491
  for (const conn of localConnections) {
2577
2492
  const src = assetIndex.get(conn.sourceAssetId);
2578
2493
  const tgt = assetIndex.get(conn.targetAssetId);
2579
- if (!src || !tgt) continue;
2580
- const sp = worldToScreen(hexToPixelX(src.q, src.r), hexToPixelY(src.q, src.r));
2581
- const tp = worldToScreen(hexToPixelX(tgt.q, tgt.r), hexToPixelY(tgt.q, tgt.r));
2582
- ctx.beginPath(); ctx.moveTo(sp.x, sp.y); ctx.lineTo(tp.x, tp.y); ctx.stroke();
2494
+ if (!src||!tgt) continue;
2495
+ const sp=w2s(htp_x(src.q,src.r),htp_y(src.q,src.r));
2496
+ const tp=w2s(htp_x(tgt.q,tgt.r),htp_y(tgt.q,tgt.r));
2497
+ ctx.beginPath();ctx.moveTo(sp.x,sp.y);ctx.lineTo(tp.x,tp.y);ctx.stroke();
2583
2498
  }
2584
2499
  ctx.setLineDash([]);
2585
2500
  ctx.restore();
2586
2501
 
2587
- // \u2500\u2500 Draw hexagons per cluster \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2588
- for (const cluster of CLUSTERS) {
2502
+ // Draw hexagons per cluster
2503
+ for (const cluster of MAP.clusters) {
2589
2504
  const baseColor = cluster.color;
2590
- const isClusterMatch = !hasSearch || cluster.assets.some(a =>
2591
- matchedIds.has(a.id)
2592
- );
2505
+ const clusterAssets = cluster.assetIds.map(id=>assetIndex.get(id)).filter(Boolean);
2506
+ const isClusterMatch = !hasSearch || clusterAssets.some(a => matchedIds.has(a.id));
2593
2507
  const clusterDim = hasSearch && !isClusterMatch;
2594
2508
 
2595
- for (let ai=0; ai<cluster.assets.length; ai++) {
2596
- const asset = cluster.assets[ai];
2597
- const wx = hexToPixelX(asset.q, asset.r);
2598
- const wy = hexToPixelY(asset.q, asset.r);
2599
- const s = worldToScreen(wx, wy);
2600
- const cx = s.x, cy = s.y;
2509
+ for (let ai=0; ai<clusterAssets.length; ai++) {
2510
+ const asset = clusterAssets[ai];
2511
+ const wx=htp_x(asset.q,asset.r), wy=htp_y(asset.q,asset.r);
2512
+ const s=w2s(wx,wy);
2513
+ const cx=s.x, cy=s.y;
2601
2514
 
2602
2515
  // Frustum cull
2603
- if (cx+size<0 || cx-size>W || cy+size<0 || cy-size>H) continue;
2516
+ if(cx+size<0||cx-size>W||cy+size<0||cy-size>H) continue;
2604
2517
 
2605
- // Shade variation: every 3rd hex slightly lighter
2606
- const shade = ai%3===0 ? 18 : ai%3===1 ? 8 : 0;
2607
- let fillColor = shadeVariant(baseColor, shade);
2518
+ // Shade variation
2519
+ const shade = ai%3===0?18:ai%3===1?8:0;
2520
+ let fillColor = shadeV(baseColor, shade);
2608
2521
 
2609
- // Quality layer override
2610
- if (showQuality && asset.qualityScore !== null) {
2522
+ // Quality overlay
2523
+ if (showQuality && asset.qualityScore !== null && asset.qualityScore !== undefined) {
2611
2524
  const q = asset.qualityScore;
2612
2525
  if (q < 40) fillColor = '#ef4444';
2613
2526
  else if (q < 70) fillColor = '#f97316';
2614
2527
  }
2615
2528
 
2616
- // Dim non-matching in search
2617
2529
  const alpha = clusterDim ? 0.18 : 1;
2618
-
2619
- // Hover / selected highlight
2620
2530
  const isHovered = asset.id === hoveredAssetId;
2621
2531
  const isSelected = asset.id === selectedAssetId;
2622
2532
  const isConnectFirst = asset.id === connectFirst;
2623
2533
 
2624
2534
  ctx.save();
2625
2535
  ctx.globalAlpha = alpha;
2626
-
2627
2536
  hexPath(cx, cy, size*0.92);
2628
2537
 
2629
- if (isDark) {
2630
- // Glow effect in dark mode
2631
- if (isHovered || isSelected || isConnectFirst) {
2632
- ctx.shadowColor = fillColor;
2633
- ctx.shadowBlur = isSelected ? 16 : 8;
2634
- }
2538
+ if (isDark && (isHovered||isSelected||isConnectFirst)) {
2539
+ ctx.shadowColor = fillColor;
2540
+ ctx.shadowBlur = isSelected ? 16 : 8;
2635
2541
  }
2636
2542
 
2637
2543
  ctx.fillStyle = fillColor;
2638
2544
  ctx.fill();
2639
2545
 
2640
- if (isSelected || isConnectFirst) {
2546
+ if (isSelected||isConnectFirst) {
2641
2547
  ctx.strokeStyle = isConnectFirst ? '#f59e0b' : '#fff';
2642
2548
  ctx.lineWidth = 2.5;
2643
2549
  ctx.stroke();
@@ -2650,352 +2556,1389 @@ function draw() {
2650
2556
  ctx.lineWidth = 1;
2651
2557
  ctx.stroke();
2652
2558
  }
2653
-
2654
2559
  ctx.restore();
2655
2560
 
2656
- // Quality dot indicator
2657
- if (showQuality && asset.qualityScore !== null && size > 8) {
2561
+ // Quality dot
2562
+ if (showQuality && asset.qualityScore!==null && asset.qualityScore!==undefined && size>8) {
2658
2563
  const q = asset.qualityScore;
2659
2564
  if (q < 70) {
2660
2565
  ctx.beginPath();
2661
- ctx.arc(cx+size*0.4, cy-size*0.4, Math.max(3, size*0.14), 0, Math.PI*2);
2662
- ctx.fillStyle = q<40 ? '#ef4444' : '#f97316';
2566
+ ctx.arc(cx+size*0.4, cy-size*0.4, Math.max(3,size*0.14), 0, Math.PI*2);
2567
+ ctx.fillStyle = q<40?'#ef4444':'#f97316';
2663
2568
  ctx.fill();
2664
2569
  }
2665
2570
  }
2666
2571
 
2667
- // Asset-level labels (detail 4, or 3 at high zoom)
2668
- const showAssetLabel = showLabels && !clusterDim && (
2669
- (detailLevel >= 4) ||
2670
- (detailLevel === 3 && scale >= 0.8)
2671
- );
2672
- if (showAssetLabel && size > 14) {
2673
- const label = asset.name.length > 12 ? asset.name.substring(0,11)+'\u2026' : asset.name;
2572
+ // Asset labels (detail 4, or 3 at high zoom)
2573
+ const showAssetLabel = showLabels && !clusterDim &&
2574
+ ((detailLevel>=4)||(detailLevel===3 && scale>=0.8));
2575
+ if (showAssetLabel && size>14) {
2576
+ const label = asset.name.length>12 ? asset.name.substring(0,11)+'...' : asset.name;
2674
2577
  ctx.save();
2675
- ctx.font = \`\${Math.max(8, Math.min(11, size*0.38))}px -apple-system,sans-serif\`;
2578
+ ctx.font = Math.max(8,Math.min(11,size*0.38))+'px -apple-system,sans-serif';
2676
2579
  ctx.fillStyle = isDark ? 'rgba(255,255,255,0.85)' : 'rgba(255,255,255,0.9)';
2677
- ctx.textAlign = 'center';
2678
- ctx.textBaseline = 'middle';
2580
+ ctx.textAlign='center';ctx.textBaseline='middle';
2679
2581
  ctx.fillText(label, cx, cy);
2680
2582
  ctx.restore();
2681
2583
  }
2682
2584
  }
2683
2585
  }
2684
2586
 
2685
- // \u2500\u2500 Cluster labels (pill badges) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2686
- if (showLabels && detailLevel >= 1) {
2687
- for (const cluster of CLUSTERS) {
2688
- if (cluster.assets.length === 0) continue;
2689
- const hasSearch_ = searchQuery.length > 0;
2690
- const isMatch = !hasSearch_ || cluster.assets.some(a => getSearchMatches().has(a.id));
2691
- if (hasSearch_ && !isMatch) continue;
2692
-
2693
- const s = worldToScreen(cluster.centroid.x, cluster.centroid.y);
2694
- drawPillLabel(s.x, s.y, cluster.label, cluster.color, 14, isDark);
2587
+ // Cluster labels (pill badges)
2588
+ if (showLabels && detailLevel>=1) {
2589
+ for (const cluster of MAP.clusters) {
2590
+ if (cluster.assetIds.length===0) continue;
2591
+ if (hasSearch && !cluster.assetIds.some(id=>matchedIds.has(id))) continue;
2592
+ const s=w2s(cluster.centroid.x, cluster.centroid.y);
2593
+ drawPill(s.x, s.y-size*1.2, cluster.label, cluster.color, 14);
2695
2594
  }
2696
2595
  }
2697
2596
 
2698
- // \u2500\u2500 Sub-cluster labels (detail 2+) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2699
- if (showLabels && detailLevel >= 2) {
2700
- for (const [clusterId, subs] of Object.entries(SUB_CLUSTERS)) {
2701
- for (const sub of subs) {
2702
- const s = worldToScreen(sub.centroid.x, sub.centroid.y);
2703
- // Offset slightly below cluster centroid
2704
- drawPillLabel(s.x, s.y + size*1.8, sub.subDomain, '#64748b', 11, isDark);
2705
- }
2597
+ // Sub-domain labels (detail 2+)
2598
+ if (showLabels && detailLevel>=2) {
2599
+ const subGroups = new Map();
2600
+ for (const a of MAP.assets) {
2601
+ if (!a.subDomain) continue;
2602
+ const key = a.domain+'|'+a.subDomain;
2603
+ if (!subGroups.has(key)) subGroups.set(key, []);
2604
+ subGroups.get(key).push(a);
2605
+ }
2606
+ for (const [, group] of subGroups) {
2607
+ let sx=0,sy=0;
2608
+ for (const a of group) { sx+=htp_x(a.q,a.r); sy+=htp_y(a.q,a.r); }
2609
+ const cx=sx/group.length, cy=sy/group.length;
2610
+ const s = w2s(cx, cy);
2611
+ drawPill(s.x, s.y+size*1.5, group[0].subDomain, '#64748b', 11);
2706
2612
  }
2707
2613
  }
2708
2614
  }
2709
2615
 
2710
- function shadeVariant(hex, amount) {
2711
- if (!hex || hex.length < 7) return hex;
2712
- const num = parseInt(hex.replace('#',''), 16);
2713
- const r = Math.min(255, (num>>16) + amount);
2714
- const g = Math.min(255, ((num>>8)&0xff) + amount);
2715
- const b = Math.min(255, (num&0xff) + amount);
2716
- return '#' + r.toString(16).padStart(2,'0') + g.toString(16).padStart(2,'0') + b.toString(16).padStart(2,'0');
2717
- }
2718
-
2719
- function drawPillLabel(x, y, text, color, fontSize, dark) {
2720
- if (!text) return;
2616
+ function drawPill(x, y, text, color, fontSize) {
2617
+ if(!text) return;
2721
2618
  ctx.save();
2722
- ctx.font = \`600 \${fontSize}px -apple-system,sans-serif\`;
2723
- const tw = ctx.measureText(text).width;
2724
- const ph = fontSize+8, pw = tw+20;
2725
- // Pill background
2619
+ ctx.font = '600 '+fontSize+'px -apple-system,sans-serif';
2620
+ const tw=ctx.measureText(text).width;
2621
+ const ph=fontSize+8, pw=tw+20;
2726
2622
  ctx.beginPath();
2727
- ctx.roundRect(x-pw/2, y-ph/2, pw, ph, ph/2);
2728
- ctx.fillStyle = dark ? 'rgba(30,41,59,0.9)' : 'rgba(255,255,255,0.92)';
2729
- ctx.shadowColor = 'rgba(0,0,0,0.15)';
2730
- ctx.shadowBlur = 6;
2731
- ctx.fill();
2732
- ctx.shadowBlur = 0;
2733
- // Text
2734
- ctx.fillStyle = dark ? '#e2e8f0' : '#0f172a';
2735
- ctx.textAlign = 'center';
2736
- ctx.textBaseline = 'middle';
2623
+ if (ctx.roundRect) ctx.roundRect(x-pw/2, y-ph/2, pw, ph, ph/2);
2624
+ else { ctx.rect(x-pw/2, y-ph/2, pw, ph); }
2625
+ ctx.fillStyle = isDark ? 'rgba(30,41,59,0.9)' : 'rgba(255,255,255,0.92)';
2626
+ ctx.shadowColor='rgba(0,0,0,0.15)'; ctx.shadowBlur=6;
2627
+ ctx.fill(); ctx.shadowBlur=0;
2628
+ ctx.fillStyle = isDark ? '#e2e8f0' : '#0f172a';
2629
+ ctx.textAlign='center'; ctx.textBaseline='middle';
2737
2630
  ctx.fillText(text, x, y);
2738
2631
  ctx.restore();
2739
2632
  }
2740
2633
 
2741
2634
  // \u2500\u2500 Hit testing \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2742
- function getAssetAtScreen(sx, sy) {
2743
- const w = screenToWorld(sx, sy);
2744
- const size = HEX_SIZE;
2745
- for (const cluster of CLUSTERS) {
2746
- for (const asset of cluster.assets) {
2747
- const wx = hexToPixelX(asset.q, asset.r);
2748
- const wy = hexToPixelY(asset.q, asset.r);
2749
- const dx = Math.abs(w.x-wx), dy = Math.abs(w.y-wy);
2750
- const hw = Math.sqrt(3)/2*size;
2751
- if (dx > hw || dy > size) continue;
2752
- if (hw*size - size*dx - (hw/2)*dy >= 0) return asset;
2753
- }
2635
+ function getAssetAt(sx, sy) {
2636
+ const w=s2w(sx,sy);
2637
+ for (const a of MAP.assets) {
2638
+ const wx=htp_x(a.q,a.r), wy=htp_y(a.q,a.r);
2639
+ const dx=Math.abs(w.x-wx), dy=Math.abs(w.y-wy);
2640
+ if (dx>HEX_SIZE||dy>HEX_SIZE) continue;
2641
+ if (dx*dx+dy*dy < HEX_SIZE*HEX_SIZE) return a;
2754
2642
  }
2755
2643
  return null;
2756
2644
  }
2757
2645
 
2758
2646
  // \u2500\u2500 Search \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2759
2647
  function getSearchMatches() {
2760
- if (!searchQuery) return new Set();
2761
- const q = searchQuery.toLowerCase();
2762
- const matches = new Set();
2763
- for (const c of CLUSTERS) {
2764
- for (const a of c.assets) {
2765
- if (a.name.toLowerCase().includes(q) ||
2766
- (a.domain && a.domain.toLowerCase().includes(q)) ||
2767
- (a.subDomain && a.subDomain.toLowerCase().includes(q))) {
2768
- matches.add(a.id);
2769
- }
2770
- }
2648
+ if(!searchQuery) return new Set();
2649
+ const q=searchQuery.toLowerCase();
2650
+ const m=new Set();
2651
+ for(const a of MAP.assets){
2652
+ if(a.name.toLowerCase().includes(q)||(a.domain&&a.domain.toLowerCase().includes(q))||
2653
+ (a.subDomain&&a.subDomain.toLowerCase().includes(q))) m.add(a.id);
2771
2654
  }
2772
- return matches;
2655
+ return m;
2773
2656
  }
2774
2657
 
2775
2658
  // \u2500\u2500 Pan & Zoom \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2776
- let dragging = false, lastMX = 0, lastMY = 0;
2659
+ let dragging=false, lastMX=0, lastMY=0;
2777
2660
 
2778
- wrap.addEventListener('mousedown', e => {
2779
- if (e.button !== 0) return;
2780
- dragging = true; lastMX = e.clientX; lastMY = e.clientY;
2661
+ wrap.addEventListener('mousedown', e=>{
2662
+ if(e.button!==0)return;
2663
+ dragging=true; lastMX=e.clientX; lastMY=e.clientY;
2781
2664
  wrap.classList.add('dragging');
2782
2665
  });
2783
- window.addEventListener('mouseup', () => { dragging = false; wrap.classList.remove('dragging'); });
2784
- window.addEventListener('mousemove', e => {
2785
- if (dragging) {
2786
- vx += e.clientX - lastMX; vy += e.clientY - lastMY;
2787
- lastMX = e.clientX; lastMY = e.clientY;
2788
- draw();
2789
- return;
2790
- }
2791
- const rect = wrap.getBoundingClientRect();
2792
- const sx = e.clientX - rect.left, sy = e.clientY - rect.top;
2793
- const asset = getAssetAtScreen(sx, sy);
2794
- const newId = asset ? asset.id : null;
2795
- if (newId !== hoveredAssetId) { hoveredAssetId = newId; draw(); }
2796
- if (asset) {
2797
- const tooltip = document.getElementById('tooltip');
2798
- document.getElementById('tt-name').textContent = asset.name;
2799
- document.getElementById('tt-domain').textContent = asset.domain + (asset.subDomain ? ' \u203A '+asset.subDomain : '');
2800
- tooltip.style.display = 'block';
2801
- tooltip.style.left = (e.clientX+12)+'px';
2802
- tooltip.style.top = (e.clientY-8)+'px';
2803
- } else {
2804
- document.getElementById('tooltip').style.display = 'none';
2666
+ window.addEventListener('mouseup', ()=>{dragging=false;wrap.classList.remove('dragging');});
2667
+ window.addEventListener('mousemove', e=>{
2668
+ if(dragging){
2669
+ vx+=e.clientX-lastMX; vy+=e.clientY-lastMY;
2670
+ lastMX=e.clientX; lastMY=e.clientY; draw(); return;
2805
2671
  }
2672
+ const rect=wrap.getBoundingClientRect();
2673
+ const sx=e.clientX-rect.left, sy=e.clientY-rect.top;
2674
+ const asset=getAssetAt(sx,sy);
2675
+ const newId=asset?asset.id:null;
2676
+ if(newId!==hoveredAssetId){hoveredAssetId=newId;draw();}
2677
+ const tt=document.getElementById('tooltip');
2678
+ if(asset){
2679
+ document.getElementById('tt-name').textContent=asset.name;
2680
+ document.getElementById('tt-domain').textContent=asset.domain+(asset.subDomain?' > '+asset.subDomain:'');
2681
+ document.getElementById('tt-quality').textContent=asset.qualityScore!==null?'Quality: '+asset.qualityScore+'/100':'';
2682
+ tt.style.display='block';tt.style.left=(e.clientX+12)+'px';tt.style.top=(e.clientY-8)+'px';
2683
+ } else { tt.style.display='none'; }
2806
2684
  });
2807
2685
 
2808
- wrap.addEventListener('click', e => {
2809
- const rect = wrap.getBoundingClientRect();
2810
- const sx = e.clientX-rect.left, sy = e.clientY-rect.top;
2811
- const asset = getAssetAtScreen(sx, sy);
2812
- if (connectMode) {
2813
- if (!asset) return;
2814
- if (!connectFirst) {
2815
- connectFirst = asset.id; draw();
2816
- } else if (connectFirst !== asset.id) {
2817
- // Create connection
2818
- const conn = {id: crypto.randomUUID(), sourceAssetId: connectFirst, targetAssetId: asset.id, type:'connection'};
2819
- localConnections.push(conn);
2820
- connectFirst = null;
2821
- draw();
2686
+ wrap.addEventListener('click', e=>{
2687
+ const rect=wrap.getBoundingClientRect();
2688
+ const sx=e.clientX-rect.left, sy=e.clientY-rect.top;
2689
+ const asset=getAssetAt(sx,sy);
2690
+ if(connectMode){
2691
+ if(!asset) return;
2692
+ if(!connectFirst){connectFirst=asset.id;draw();}
2693
+ else if(connectFirst!==asset.id){
2694
+ localConnections.push({id:crypto.randomUUID(),sourceAssetId:connectFirst,targetAssetId:asset.id,type:'connection'});
2695
+ connectFirst=null;draw();
2822
2696
  }
2823
2697
  return;
2824
2698
  }
2825
- if (asset) {
2826
- selectedAssetId = asset.id;
2827
- showDetailPanel(asset);
2828
- } else {
2829
- selectedAssetId = null;
2830
- document.getElementById('detail-panel').classList.remove('open');
2831
- }
2699
+ if(asset){selectedAssetId=asset.id;showDetailPanel(asset);}
2700
+ else{selectedAssetId=null;document.getElementById('detail-panel').classList.remove('open');}
2832
2701
  draw();
2833
2702
  });
2834
2703
 
2835
- // Touch pan
2836
- let lastTouches = [];
2837
- wrap.addEventListener('touchstart', e => { lastTouches = [...e.touches]; }, { passive:true });
2838
- wrap.addEventListener('touchmove', e => {
2839
- if (e.touches.length === 1) {
2840
- const dx = e.touches[0].clientX - lastTouches[0].clientX;
2841
- const dy = e.touches[0].clientY - lastTouches[0].clientY;
2842
- vx += dx; vy += dy; draw();
2843
- } else if (e.touches.length === 2) {
2844
- // Pinch zoom
2845
- const d0 = Math.hypot(lastTouches[0].clientX-lastTouches[1].clientX, lastTouches[0].clientY-lastTouches[1].clientY);
2846
- const d1 = Math.hypot(e.touches[0].clientX-e.touches[1].clientX, e.touches[0].clientY-e.touches[1].clientY);
2847
- const mx = (e.touches[0].clientX+e.touches[1].clientX)/2;
2848
- const my = (e.touches[0].clientY+e.touches[1].clientY)/2;
2849
- applyZoom(d1/d0, mx, my);
2850
- }
2851
- lastTouches = [...e.touches];
2852
- }, { passive:true });
2853
-
2854
- wrap.addEventListener('wheel', e => {
2704
+ // Touch
2705
+ let lastTouches=[];
2706
+ wrap.addEventListener('touchstart',e=>{lastTouches=[...e.touches];},{passive:true});
2707
+ wrap.addEventListener('touchmove',e=>{
2708
+ if(e.touches.length===1){
2709
+ vx+=e.touches[0].clientX-lastTouches[0].clientX;
2710
+ vy+=e.touches[0].clientY-lastTouches[0].clientY;draw();
2711
+ } else if(e.touches.length===2){
2712
+ const d0=Math.hypot(lastTouches[0].clientX-lastTouches[1].clientX,lastTouches[0].clientY-lastTouches[1].clientY);
2713
+ const d1=Math.hypot(e.touches[0].clientX-e.touches[1].clientX,e.touches[0].clientY-e.touches[1].clientY);
2714
+ const mx=(e.touches[0].clientX+e.touches[1].clientX)/2;
2715
+ const my=(e.touches[0].clientY+e.touches[1].clientY)/2;
2716
+ applyZoom(d1/d0,mx,my);
2717
+ }
2718
+ lastTouches=[...e.touches];
2719
+ },{passive:true});
2720
+
2721
+ wrap.addEventListener('wheel',e=>{
2855
2722
  e.preventDefault();
2856
- const rect = wrap.getBoundingClientRect();
2857
- const factor = e.deltaY < 0 ? 1.12 : 1/1.12;
2858
- applyZoom(factor, e.clientX-rect.left, e.clientY-rect.top);
2859
- }, { passive:false });
2860
-
2861
- function applyZoom(factor, sx, sy) {
2862
- const newScale = Math.max(0.05, Math.min(8, scale*factor));
2863
- const wx = (sx-vx)/scale, wy = (sy-vy)/scale;
2864
- scale = newScale;
2865
- vx = sx - wx*scale; vy = sy - wy*scale;
2866
- document.getElementById('zoom-pct').textContent = Math.round(scale*100)+'%';
2867
- draw();
2723
+ const rect=wrap.getBoundingClientRect();
2724
+ applyZoom(e.deltaY<0?1.12:1/1.12,e.clientX-rect.left,e.clientY-rect.top);
2725
+ },{passive:false});
2726
+
2727
+ function applyZoom(factor,sx,sy){
2728
+ const ns=Math.max(0.05,Math.min(8,scale*factor));
2729
+ const wx=(sx-vx)/scale,wy=(sy-vy)/scale;
2730
+ scale=ns;vx=sx-wx*scale;vy=sy-wy*scale;
2731
+ document.getElementById('zoom-pct').textContent=Math.round(scale*100)+'%';draw();
2868
2732
  }
2869
-
2870
- document.getElementById('zoom-in').addEventListener('click', () => applyZoom(1.25, W/2, H/2));
2871
- document.getElementById('zoom-out').addEventListener('click', () => applyZoom(1/1.25, W/2, H/2));
2872
-
2873
- // Keyboard navigation
2874
- wrap.addEventListener('keydown', e => {
2875
- const step = 40;
2876
- if (e.key === 'ArrowLeft') { vx += step; draw(); }
2877
- else if (e.key === 'ArrowRight') { vx -= step; draw(); }
2878
- else if (e.key === 'ArrowUp') { vy += step; draw(); }
2879
- else if (e.key === 'ArrowDown') { vy -= step; draw(); }
2880
- else if (e.key === '+' || e.key === '=') applyZoom(1.2, W/2, H/2);
2881
- else if (e.key === '-') applyZoom(1/1.2, W/2, H/2);
2882
- else if (e.key === 'Escape') {
2883
- selectedAssetId = null;
2884
- document.getElementById('detail-panel').classList.remove('open');
2885
- if (connectMode) toggleConnectMode();
2886
- draw();
2733
+ document.getElementById('zoom-in').addEventListener('click',()=>applyZoom(1.25,W/2,H/2));
2734
+ document.getElementById('zoom-out').addEventListener('click',()=>applyZoom(1/1.25,W/2,H/2));
2735
+
2736
+ // Keyboard
2737
+ wrap.addEventListener('keydown',e=>{
2738
+ const step=40;
2739
+ if(e.key==='ArrowLeft'){vx+=step;draw();}
2740
+ else if(e.key==='ArrowRight'){vx-=step;draw();}
2741
+ else if(e.key==='ArrowUp'){vy+=step;draw();}
2742
+ else if(e.key==='ArrowDown'){vy-=step;draw();}
2743
+ else if(e.key==='+'||e.key==='=')applyZoom(1.2,W/2,H/2);
2744
+ else if(e.key==='-')applyZoom(1/1.2,W/2,H/2);
2745
+ else if(e.key==='Escape'){
2746
+ selectedAssetId=null;document.getElementById('detail-panel').classList.remove('open');
2747
+ if(connectMode)toggleConnect();draw();
2887
2748
  }
2888
2749
  });
2889
2750
 
2890
2751
  // \u2500\u2500 Detail Panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2891
2752
  function showDetailPanel(asset) {
2892
- document.getElementById('dp-name').textContent = asset.name;
2893
- const body = document.getElementById('dp-body');
2894
- const rows = [
2895
- ['Domain', asset.domain],
2896
- ['Sub-domain', asset.subDomain],
2897
- ['Quality Score', asset.qualityScore !== null ? renderQuality(asset.qualityScore) : null],
2898
- ...Object.entries(asset.metadata || {}).slice(0,8).map(([k,v]) => [k, String(v)]),
2899
- ].filter(([,v]) => v !== null && v !== undefined && v !== '');
2900
-
2901
- body.innerHTML = rows.map(([label, value]) => \`
2902
- <div class="meta-row">
2903
- <div class="meta-label">\${escHtml(String(label))}</div>
2904
- <div class="meta-value">\${value}</div>
2905
- </div>\`).join('');
2906
-
2907
- // Connections
2908
- const related = localConnections.filter(c => c.sourceAssetId===asset.id || c.targetAssetId===asset.id);
2909
- if (related.length > 0) {
2910
- body.innerHTML += \`<div class="meta-row"><div class="meta-label">Connections (\${related.length})</div><div>\${
2911
- related.map(c => {
2912
- const otherId = c.sourceAssetId===asset.id ? c.targetAssetId : c.sourceAssetId;
2913
- const other = assetIndex.get(otherId);
2914
- return \`<div class="meta-value" style="margin-top:4px;font-size:12px">\${other ? escHtml(other.name) : otherId}</div>\`;
2915
- }).join('')
2916
- }</div></div>\`;
2753
+ document.getElementById('dp-name').textContent=asset.name;
2754
+ const body=document.getElementById('dp-body');
2755
+ const rows=[['Domain',asset.domain],['Sub-domain',asset.subDomain],
2756
+ ['Quality Score',asset.qualityScore!==null?renderQuality(asset.qualityScore):null],
2757
+ ...Object.entries(asset.metadata||{}).slice(0,8).map(([k,v])=>[k,String(v)])
2758
+ ].filter(([,v])=>v!==null&&v!==undefined&&v!=='');
2759
+ body.innerHTML=rows.map(([l,v])=>'<div class="meta-row"><div class="meta-label">'+esc(String(l))+'</div><div class="meta-value">'+v+'</div></div>').join('');
2760
+ const related=localConnections.filter(c=>c.sourceAssetId===asset.id||c.targetAssetId===asset.id);
2761
+ if(related.length>0){
2762
+ body.innerHTML+='<div class="meta-row"><div class="meta-label">Connections ('+related.length+')</div><div>'+
2763
+ related.map(c=>{const oid=c.sourceAssetId===asset.id?c.targetAssetId:c.sourceAssetId;
2764
+ const o=assetIndex.get(oid);return '<div class="meta-value" style="margin-top:4px;font-size:12px">'+(o?esc(o.name):oid)+'</div>';}).join('')+'</div></div>';
2917
2765
  }
2918
-
2919
2766
  document.getElementById('detail-panel').classList.add('open');
2920
2767
  }
2768
+ function renderQuality(s){
2769
+ const c=s>=70?'#22c55e':s>=40?'#f97316':'#ef4444';
2770
+ return s+'/100 <div class="quality-bar"><div class="quality-fill" style="width:'+s+'%;background:'+c+'"></div></div>';
2771
+ }
2772
+ function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
2773
+ document.getElementById('dp-close').addEventListener('click',()=>{
2774
+ document.getElementById('detail-panel').classList.remove('open');selectedAssetId=null;draw();
2775
+ });
2921
2776
 
2922
- function renderQuality(score) {
2923
- const color = score>=70 ? '#22c55e' : score>=40 ? '#f97316' : '#ef4444';
2924
- return \`\${score}/100 <div class="quality-bar"><div class="quality-fill" style="width:\${score}%;background:\${color}"></div></div>\`;
2777
+ // \u2500\u2500 Toolbar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2778
+ [1,2,3,4].forEach(n=>{
2779
+ document.getElementById('dl-'+n).addEventListener('click',()=>{
2780
+ detailLevel=n;document.querySelectorAll('.detail-btn').forEach(b=>b.classList.remove('active'));
2781
+ document.getElementById('dl-'+n).classList.add('active');draw();
2782
+ });
2783
+ });
2784
+ document.getElementById('btn-labels').addEventListener('click',()=>{
2785
+ showLabels=!showLabels;document.getElementById('btn-labels').classList.toggle('active',showLabels);draw();
2786
+ });
2787
+ document.getElementById('btn-quality').addEventListener('click',()=>{
2788
+ showQuality=!showQuality;document.getElementById('btn-quality').classList.toggle('active',showQuality);draw();
2789
+ });
2790
+ function toggleConnect(){
2791
+ connectMode=!connectMode;connectFirst=null;
2792
+ document.getElementById('connect-btn').classList.toggle('active',connectMode);
2793
+ wrap.classList.toggle('connecting',connectMode);
2794
+ document.getElementById('connect-hint').style.display=connectMode?'block':'none';draw();
2795
+ }
2796
+ document.getElementById('connect-btn').addEventListener('click',toggleConnect);
2797
+ document.getElementById('theme-btn').addEventListener('click',()=>{
2798
+ isDark=!isDark;
2799
+ document.body.classList.toggle('dark',isDark);document.body.classList.toggle('light',!isDark);
2800
+ document.getElementById('theme-btn').innerHTML=isDark?'&#9788;':'&#9790;';draw();
2801
+ });
2802
+ document.getElementById('search-input').addEventListener('input',e=>{searchQuery=e.target.value.trim();draw();});
2803
+
2804
+ // \u2500\u2500 Init \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2805
+ resize(); fitToView();
2806
+ document.getElementById('zoom-pct').textContent=Math.round(scale*100)+'%';
2807
+ draw();
2808
+ })();
2809
+ </script>
2810
+ </body>
2811
+ </html>`;
2812
+ }
2813
+ function exportDiscoveryApp(nodes, edges, options) {
2814
+ const theme = options?.theme ?? "dark";
2815
+ const graphData = JSON.stringify({
2816
+ nodes: nodes.map((n) => ({
2817
+ id: n.id,
2818
+ name: n.name,
2819
+ type: n.type,
2820
+ layer: nodeLayer(n.type),
2821
+ confidence: n.confidence,
2822
+ discoveredVia: n.discoveredVia,
2823
+ discoveredAt: n.discoveredAt,
2824
+ tags: n.tags,
2825
+ metadata: n.metadata
2826
+ })),
2827
+ links: edges.map((e) => ({
2828
+ source: e.sourceId,
2829
+ target: e.targetId,
2830
+ relationship: e.relationship,
2831
+ confidence: e.confidence,
2832
+ evidence: e.evidence
2833
+ }))
2834
+ });
2835
+ const { assets, clusters, connections } = buildMapData(nodes, edges, { theme });
2836
+ const isEmpty = assets.length === 0;
2837
+ const HEX_SIZE2 = 24;
2838
+ const mapJson = JSON.stringify({
2839
+ assets: assets.map((a) => ({
2840
+ id: a.id,
2841
+ name: a.name,
2842
+ domain: a.domain,
2843
+ subDomain: a.subDomain ?? null,
2844
+ qualityScore: a.qualityScore ?? null,
2845
+ metadata: a.metadata,
2846
+ q: a.position.q,
2847
+ r: a.position.r
2848
+ })),
2849
+ clusters: clusters.map((c) => ({
2850
+ id: c.id,
2851
+ label: c.label,
2852
+ domain: c.domain,
2853
+ color: c.color,
2854
+ assetIds: c.assetIds,
2855
+ centroid: c.centroid
2856
+ })),
2857
+ connections: connections.map((c) => ({
2858
+ id: c.id,
2859
+ sourceAssetId: c.sourceAssetId,
2860
+ targetAssetId: c.targetAssetId,
2861
+ type: c.type ?? "connection"
2862
+ }))
2863
+ });
2864
+ const nodeCount = nodes.length;
2865
+ const edgeCount = edges.length;
2866
+ const assetCount = assets.length;
2867
+ const clusterCount = clusters.length;
2868
+ return `<!DOCTYPE html>
2869
+ <html lang="en" data-theme="${theme}">
2870
+ <head>
2871
+ <meta charset="UTF-8"/>
2872
+ <meta name="viewport" content="width=device-width,initial-scale=1.0"/>
2873
+ <title>Cartography \u2014 Datasynx Discovery</title>
2874
+ <script src="https://d3js.org/d3.v7.min.js"></script>
2875
+ <style>
2876
+ /* \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 */
2877
+ :root{
2878
+ --bg-base:#0f172a;--bg-surface:#1e293b;--bg-elevated:#273148;
2879
+ --border:#334155;--border-dim:#1e293b;
2880
+ --text:#e2e8f0;--text-muted:#94a3b8;--text-dim:#475569;
2881
+ --accent:#3b82f6;--accent-hover:#2563eb;--accent-dim:rgba(59,130,246,.12);
2882
+ }
2883
+ [data-theme="light"]{
2884
+ --bg-base:#f8fafc;--bg-surface:#ffffff;--bg-elevated:#f1f5f9;
2885
+ --border:#e2e8f0;--border-dim:#f1f5f9;
2886
+ --text:#0f172a;--text-muted:#64748b;--text-dim:#94a3b8;
2887
+ --accent:#2563eb;--accent-hover:#1d4ed8;--accent-dim:rgba(37,99,235,.08);
2925
2888
  }
2926
2889
 
2927
- function escHtml(s) {
2928
- return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
2890
+ /* \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 */
2891
+ *{box-sizing:border-box;margin:0;padding:0}
2892
+ html,body{width:100%;height:100%;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Inter',sans-serif}
2893
+ body{display:flex;flex-direction:column;background:var(--bg-base);color:var(--text)}
2894
+
2895
+ /* \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 */
2896
+ #topbar{
2897
+ height:56px;display:flex;align-items:center;gap:16px;padding:0 20px;
2898
+ background:var(--bg-surface);border-bottom:1px solid var(--border);z-index:100;flex-shrink:0;
2899
+ }
2900
+ .tb-left{display:flex;align-items:center;gap:10px}
2901
+ .brand-logo{flex-shrink:0}
2902
+ .brand-name{font-size:15px;font-weight:700;color:var(--accent);letter-spacing:-.02em}
2903
+ .brand-product{font-size:14px;font-weight:500;color:var(--text-muted);margin-left:2px}
2904
+ .brand-sep{width:1px;height:24px;background:var(--border);margin:0 6px}
2905
+ .tb-center{display:flex;align-items:center;gap:2px;margin-left:auto;
2906
+ background:var(--bg-elevated);border-radius:8px;padding:3px}
2907
+ .tab-btn{
2908
+ padding:6px 16px;border:none;border-radius:6px;font-size:13px;font-weight:500;
2909
+ cursor:pointer;color:var(--text-muted);background:transparent;font-family:inherit;
2910
+ transition:all .15s;
2911
+ }
2912
+ .tab-btn:hover{color:var(--text)}
2913
+ .tab-btn.active{background:var(--accent);color:#fff;box-shadow:0 1px 3px rgba(0,0,0,.2)}
2914
+ .tb-right{display:flex;align-items:center;gap:8px;margin-left:auto}
2915
+ .tb-search{
2916
+ display:flex;align-items:center;gap:6px;background:var(--bg-elevated);
2917
+ border:1px solid var(--border);border-radius:8px;padding:5px 10px;
2918
+ }
2919
+ .tb-search input{
2920
+ border:none;background:transparent;font-size:13px;outline:none;width:160px;
2921
+ color:var(--text);font-family:inherit;
2922
+ }
2923
+ .tb-search input::placeholder{color:var(--text-dim)}
2924
+ .tb-search svg{flex-shrink:0;color:var(--text-dim)}
2925
+ .icon-btn{
2926
+ width:36px;height:36px;border-radius:8px;border:1px solid var(--border);
2927
+ background:var(--bg-surface);cursor:pointer;display:flex;align-items:center;
2928
+ justify-content:center;color:var(--text-muted);text-decoration:none;transition:all .15s;font-size:16px;
2929
+ }
2930
+ .icon-btn:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-dim)}
2931
+ .tb-stats{font-size:11px;color:var(--text-dim);white-space:nowrap}
2932
+
2933
+ /* \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 */
2934
+ .view{flex:1;display:none;overflow:hidden;position:relative}
2935
+ .view.active{display:flex}
2936
+
2937
+ /* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
2938
+ MAP VIEW
2939
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 */
2940
+ #map-wrap{flex:1;position:relative;overflow:hidden;cursor:grab}
2941
+ #map-wrap.dragging{cursor:grabbing}
2942
+ #map-wrap.connecting{cursor:crosshair}
2943
+ #map-wrap canvas{display:block;width:100%;height:100%}
2944
+ #map-detail{
2945
+ width:280px;background:var(--bg-surface);border-left:1px solid var(--border);
2946
+ display:flex;flex-direction:column;transform:translateX(100%);
2947
+ transition:transform .2s ease;z-index:5;flex-shrink:0;overflow-y:auto;
2948
+ }
2949
+ #map-detail.open{transform:translateX(0)}
2950
+ #map-detail .panel-header{
2951
+ padding:16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;
2952
+ }
2953
+ #map-detail .panel-header h3{font-size:14px;font-weight:600;flex:1;word-break:break-word}
2954
+ .close-btn{
2955
+ width:24px;height:24px;border:none;background:transparent;cursor:pointer;
2956
+ color:var(--text-muted);border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:16px;
2957
+ }
2958
+ .close-btn:hover{background:var(--bg-elevated)}
2959
+ .panel-body{padding:12px 16px;display:flex;flex-direction:column;gap:12px}
2960
+ .meta-row{display:flex;flex-direction:column;gap:3px}
2961
+ .meta-label{font-size:11px;font-weight:500;color:var(--text-dim);text-transform:uppercase;letter-spacing:.05em}
2962
+ .meta-value{font-size:13px;word-break:break-all}
2963
+ .quality-bar{height:6px;border-radius:3px;background:var(--bg-elevated);margin-top:4px}
2964
+ .quality-fill{height:6px;border-radius:3px;transition:width .3s}
2965
+
2966
+ /* Map toolbars */
2967
+ #map-tb-left{position:absolute;bottom:20px;left:20px;display:flex;gap:8px;z-index:10}
2968
+ #map-tb-right{position:absolute;bottom:20px;right:20px;display:flex;flex-direction:column;align-items:flex-end;gap:8px;z-index:10}
2969
+ .tb-tool{
2970
+ width:40px;height:40px;border-radius:10px;border:1px solid var(--border);
2971
+ background:var(--bg-surface);box-shadow:0 1px 4px rgba(0,0,0,.08);cursor:pointer;
2972
+ display:flex;align-items:center;justify-content:center;font-size:18px;
2973
+ transition:all .15s;color:var(--text);
2974
+ }
2975
+ .tb-tool:hover{border-color:var(--text-muted)}
2976
+ .tb-tool.active{background:var(--accent-dim);border-color:var(--accent)}
2977
+ .map-zoom{display:flex;align-items:center;gap:6px}
2978
+ .zoom-btn{
2979
+ width:34px;height:34px;border-radius:8px;border:1px solid var(--border);
2980
+ background:var(--bg-surface);cursor:pointer;font-size:18px;color:var(--text);
2981
+ display:flex;align-items:center;justify-content:center;
2982
+ }
2983
+ .zoom-btn:hover{background:var(--bg-elevated)}
2984
+ #map-zoom-pct{font-size:12px;font-weight:500;color:var(--text-dim);min-width:38px;text-align:center}
2985
+ #map-connect-hint{
2986
+ position:absolute;top:12px;left:50%;transform:translateX(-50%);
2987
+ background:#fef3c7;border:1px solid #f59e0b;color:#92400e;
2988
+ padding:6px 14px;border-radius:20px;font-size:12px;font-weight:500;
2989
+ display:none;z-index:20;pointer-events:none;
2990
+ }
2991
+ #map-tooltip{
2992
+ position:fixed;background:var(--bg-surface);color:var(--text);border-radius:8px;
2993
+ padding:8px 12px;font-size:12px;pointer-events:none;z-index:200;
2994
+ display:none;max-width:220px;box-shadow:0 4px 12px rgba(0,0,0,.25);border:1px solid var(--border);
2995
+ }
2996
+ #map-tooltip .tt-name{font-weight:600;margin-bottom:2px}
2997
+ #map-tooltip .tt-domain{color:var(--text-muted);font-size:11px}
2998
+ #map-tooltip .tt-quality{font-size:11px;margin-top:2px}
2999
+ #map-empty{
3000
+ position:absolute;inset:0;display:flex;flex-direction:column;
3001
+ align-items:center;justify-content:center;gap:12px;color:var(--text-muted);
3002
+ }
3003
+ #map-empty p{font-size:14px}
3004
+
3005
+ /* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3006
+ TOPOLOGY VIEW
3007
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 */
3008
+ #topo-panel{
3009
+ width:220px;min-width:220px;height:100%;overflow:hidden;
3010
+ background:var(--bg-surface);border-right:1px solid var(--border);
3011
+ display:flex;flex-direction:column;
3012
+ }
3013
+ #topo-panel-header{
3014
+ padding:10px 12px 8px;border-bottom:1px solid var(--border);
3015
+ font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.6px;
3016
+ }
3017
+ #topo-search{
3018
+ width:calc(100% - 16px);margin:8px;padding:5px 8px;
3019
+ background:var(--bg-elevated);border:1px solid var(--border);border-radius:5px;
3020
+ color:var(--text);font-size:11px;font-family:inherit;outline:none;
3021
+ }
3022
+ #topo-search:focus{border-color:var(--accent)}
3023
+ #topo-list{flex:1;overflow-y:auto;padding-bottom:8px}
3024
+ .topo-item{
3025
+ padding:5px 12px;cursor:pointer;font-size:11px;
3026
+ display:flex;align-items:center;gap:6px;border-left:2px solid transparent;
3027
+ }
3028
+ .topo-item:hover{background:var(--bg-elevated)}
3029
+ .topo-item.active{background:var(--accent-dim);border-left-color:var(--accent)}
3030
+ .topo-dot{width:7px;height:7px;border-radius:2px;flex-shrink:0}
3031
+ .topo-name{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
3032
+ .topo-type{color:var(--text-dim);font-size:9px;flex-shrink:0}
3033
+
3034
+ #topo-graph{flex:1;height:100%;position:relative}
3035
+ #topo-graph svg{width:100%;height:100%}
3036
+ .hull{opacity:.12;stroke-width:1.5;stroke-opacity:.25}
3037
+ .hull-label{font-size:13px;font-weight:700;letter-spacing:1px;text-transform:uppercase;fill-opacity:.5;pointer-events:none}
3038
+ .link{stroke-opacity:.4}
3039
+ .link-label{font-size:8px;fill:var(--text-dim);pointer-events:none;opacity:0}
3040
+ .node-hex{stroke-width:1.8;cursor:pointer;transition:opacity .15s}
3041
+ .node-hex:hover{filter:brightness(1.3);stroke-width:3}
3042
+ .node-hex.selected{stroke-width:3.5;filter:brightness(1.5)}
3043
+ .node-label{font-size:10px;fill:var(--text);pointer-events:none;opacity:0}
3044
+
3045
+ #topo-sidebar{
3046
+ width:300px;min-width:300px;height:100%;overflow-y:auto;
3047
+ background:var(--bg-surface);border-left:1px solid var(--border);
3048
+ padding:16px;font-size:12px;line-height:1.6;
3049
+ }
3050
+ #topo-sidebar h2{margin:0 0 8px;font-size:14px;color:var(--accent)}
3051
+ #topo-sidebar .meta-table{width:100%;border-collapse:collapse}
3052
+ #topo-sidebar .meta-table td{padding:3px 6px;border-bottom:1px solid var(--border-dim);vertical-align:top}
3053
+ #topo-sidebar .meta-table td:first-child{color:var(--text-dim);white-space:nowrap;width:90px}
3054
+ #topo-sidebar .tag{display:inline-block;background:var(--bg-elevated);border-radius:3px;padding:1px 5px;margin:1px;font-size:10px}
3055
+ #topo-sidebar .conf-bar{height:5px;border-radius:3px;background:var(--bg-elevated);margin-top:3px}
3056
+ #topo-sidebar .conf-fill{height:100%;border-radius:3px}
3057
+ #topo-sidebar .edges-list{margin-top:12px}
3058
+ #topo-sidebar .edge-item{padding:4px 0;border-bottom:1px solid var(--border-dim);color:var(--text-dim);font-size:11px}
3059
+ #topo-sidebar .edge-item span{color:var(--text)}
3060
+ .hint{color:var(--text-dim);font-size:11px;margin-top:8px}
3061
+
3062
+ #topo-hud{
3063
+ position:absolute;top:10px;left:10px;background:rgba(15,23,42,.88);
3064
+ padding:10px 14px;border-radius:8px;font-size:12px;border:1px solid var(--border);pointer-events:none;
3065
+ }
3066
+ #topo-hud strong{color:var(--accent)}
3067
+ #topo-hud .stats{color:var(--text-dim)}
3068
+ #topo-hud .zoom-level{color:var(--text-dim);font-size:10px;margin-top:2px}
3069
+
3070
+ #topo-toolbar{position:absolute;top:10px;right:10px;display:flex;flex-wrap:wrap;gap:4px;pointer-events:auto;align-items:center}
3071
+ .filter-btn{
3072
+ background:rgba(15,23,42,.85);border:1px solid var(--border);border-radius:6px;
3073
+ color:var(--text);padding:4px 10px;font-size:11px;cursor:pointer;
3074
+ font-family:inherit;display:flex;align-items:center;gap:5px;
2929
3075
  }
3076
+ .filter-btn:hover{border-color:var(--text-dim)}
3077
+ .filter-btn.off{opacity:.35}
3078
+ .filter-dot{width:8px;height:8px;border-radius:2px;display:inline-block}
3079
+ .export-btn{
3080
+ background:rgba(15,23,42,.85);border:1px solid var(--border);border-radius:6px;
3081
+ color:var(--accent);padding:4px 12px;font-size:11px;cursor:pointer;font-family:inherit;
3082
+ }
3083
+ .export-btn:hover{border-color:var(--accent);background:var(--accent-dim)}
3084
+ </style>
3085
+ </head>
3086
+ <body>
3087
+
3088
+ <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3089
+ TOPBAR
3090
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->
3091
+ <header id="topbar">
3092
+ <div class="tb-left">
3093
+ <svg class="brand-logo" width="32" height="32" viewBox="0 0 32 32" fill="none">
3094
+ <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"/>
3095
+ <circle cx="10" cy="16" r="2.8" fill="#60A5FA"/><circle cx="22" cy="10.5" r="2.2" fill="#38BDF8"/>
3096
+ <circle cx="22" cy="21.5" r="2.2" fill="#38BDF8"/>
3097
+ <line x1="12.5" y1="14.8" x2="19.8" y2="11.2" stroke="#93C5FD" stroke-width="1.2"/>
3098
+ <line x1="12.5" y1="17.2" x2="19.8" y2="20.8" stroke="#93C5FD" stroke-width="1.2"/>
3099
+ <line x1="22" y1="12.7" x2="22" y2="19.3" stroke="#93C5FD" stroke-width="1" stroke-dasharray="2 1.5"/>
3100
+ </svg>
3101
+ <span class="brand-name">datasynx</span>
3102
+ <span class="brand-sep"></span>
3103
+ <span class="brand-product">Cartography</span>
3104
+ </div>
3105
+ <div class="tb-center">
3106
+ <button class="tab-btn active" id="tab-map-btn" data-tab="map">Map</button>
3107
+ <button class="tab-btn" id="tab-topo-btn" data-tab="topo">Topology</button>
3108
+ </div>
3109
+ <div class="tb-right">
3110
+ <span class="tb-stats">${nodeCount} nodes &middot; ${edgeCount} edges</span>
3111
+ <div class="tb-search">
3112
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
3113
+ <circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
3114
+ </svg>
3115
+ <input id="global-search" type="text" placeholder="Search..." autocomplete="off" spellcheck="false"/>
3116
+ </div>
3117
+ <a href="https://www.linkedin.com/company/datasynx-ai/" target="_blank" rel="noopener noreferrer"
3118
+ class="icon-btn" title="Datasynx on LinkedIn" aria-label="LinkedIn">
3119
+ <svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
3120
+ <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"/>
3121
+ </svg>
3122
+ </a>
3123
+ <button id="theme-btn" class="icon-btn" title="Toggle theme" aria-label="Toggle theme">
3124
+ ${theme === "dark" ? "&#9788;" : "&#9790;"}
3125
+ </button>
3126
+ </div>
3127
+ </header>
2930
3128
 
2931
- document.getElementById('dp-close').addEventListener('click', () => {
2932
- document.getElementById('detail-panel').classList.remove('open');
2933
- selectedAssetId = null; draw();
3129
+ <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3130
+ MAP VIEW
3131
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->
3132
+ <div id="view-map" class="view active">
3133
+ <div id="map-wrap" tabindex="0" aria-label="Data cartography hex map">
3134
+ <canvas id="hexmap" aria-hidden="true"></canvas>
3135
+ ${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>' : ""}
3136
+ </div>
3137
+ <div id="map-detail">
3138
+ <div class="panel-header">
3139
+ <h3 id="md-name">&mdash;</h3>
3140
+ <button class="close-btn" id="md-close" aria-label="Close">&#10005;</button>
3141
+ </div>
3142
+ <div class="panel-body" id="md-body"></div>
3143
+ </div>
3144
+ <div id="map-tb-left">
3145
+ <button class="tb-tool active" id="btn-labels" title="Toggle labels">&#127991;</button>
3146
+ <button class="tb-tool" id="btn-quality" title="Quality layer">&#128065;</button>
3147
+ <button class="tb-tool" id="btn-connect" title="Connection tool">&#128279;</button>
3148
+ </div>
3149
+ <div id="map-tb-right">
3150
+ <div class="map-zoom">
3151
+ <button class="zoom-btn" id="mz-out">&minus;</button>
3152
+ <span id="map-zoom-pct">100%</span>
3153
+ <button class="zoom-btn" id="mz-in">+</button>
3154
+ </div>
3155
+ </div>
3156
+ <div id="map-connect-hint">Click two assets to create a connection</div>
3157
+ <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>
3158
+ </div>
3159
+
3160
+ <!-- \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3161
+ TOPOLOGY VIEW
3162
+ \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 -->
3163
+ <div id="view-topo" class="view">
3164
+ <div id="topo-panel">
3165
+ <div id="topo-panel-header">Nodes (${nodeCount})</div>
3166
+ <input id="topo-search" type="text" placeholder="Search nodes\u2026" autocomplete="off" spellcheck="false"/>
3167
+ <div id="topo-list"></div>
3168
+ </div>
3169
+ <div id="topo-graph">
3170
+ <div id="topo-hud">
3171
+ <strong>Topology</strong>&nbsp;
3172
+ <span class="stats">${nodeCount} nodes &middot; ${edgeCount} edges</span><br/>
3173
+ <span class="zoom-level">Scroll = zoom &middot; Drag = pan &middot; Click = details</span>
3174
+ </div>
3175
+ <div id="topo-toolbar"></div>
3176
+ <svg></svg>
3177
+ </div>
3178
+ <div id="topo-sidebar">
3179
+ <h2>Infrastructure Map</h2>
3180
+ <p class="hint">Click a node to view details.</p>
3181
+ </div>
3182
+ </div>
3183
+
3184
+ <script>
3185
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3186
+ // SHARED STATE
3187
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3188
+ let isDark = document.documentElement.getAttribute('data-theme') === 'dark';
3189
+ let currentTab = 'map';
3190
+ let topoInited = false;
3191
+
3192
+ // \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
3193
+ document.getElementById('theme-btn').addEventListener('click', function() {
3194
+ isDark = !isDark;
3195
+ document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
3196
+ this.innerHTML = isDark ? '\\u2606' : '\\u263E';
3197
+ if (typeof drawMap === 'function') drawMap();
2934
3198
  });
2935
3199
 
2936
- // \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
2937
- [1,2,3,4].forEach(n => {
2938
- document.getElementById('dl-'+n).addEventListener('click', () => {
2939
- detailLevel = n;
2940
- document.querySelectorAll('.detail-btn').forEach(b => b.classList.remove('active'));
2941
- document.getElementById('dl-'+n).classList.add('active');
2942
- draw();
3200
+ // \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
3201
+ document.querySelectorAll('.tab-btn').forEach(function(btn) {
3202
+ btn.addEventListener('click', function() {
3203
+ var tab = this.getAttribute('data-tab');
3204
+ if (tab === currentTab) return;
3205
+ currentTab = tab;
3206
+ document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
3207
+ this.classList.add('active');
3208
+ document.querySelectorAll('.view').forEach(function(v) { v.classList.remove('active'); });
3209
+ document.getElementById('view-' + tab).classList.add('active');
3210
+ if (tab === 'topo' && !topoInited) { initTopology(); topoInited = true; }
3211
+ if (tab === 'map' && typeof drawMap === 'function') { resizeMap(); }
2943
3212
  });
2944
3213
  });
2945
3214
 
2946
- document.getElementById('btn-org').addEventListener('click', () => {
2947
- showOrg = !showOrg;
2948
- document.getElementById('btn-org').classList.toggle('active', showOrg);
2949
- draw();
3215
+ // \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
3216
+ document.getElementById('global-search').addEventListener('input', function(e) {
3217
+ var q = e.target.value.trim();
3218
+ if (typeof setMapSearch === 'function') setMapSearch(q);
3219
+ if (typeof setTopoSearch === 'function') setTopoSearch(q);
2950
3220
  });
2951
- document.getElementById('btn-labels').addEventListener('click', () => {
2952
- showLabels = !showLabels;
2953
- document.getElementById('btn-labels').classList.toggle('active', showLabels);
2954
- draw();
3221
+
3222
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3223
+ // MAP VIEW
3224
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3225
+ var MAP = ${mapJson};
3226
+ var MAP_HEX = ${HEX_SIZE2};
3227
+ var MAP_EMPTY = ${isEmpty};
3228
+
3229
+ var mapAssetIndex = new Map();
3230
+ var mapClusterByAsset = new Map();
3231
+ for (var ci = 0; ci < MAP.clusters.length; ci++) {
3232
+ var c = MAP.clusters[ci];
3233
+ for (var ai = 0; ai < c.assetIds.length; ai++) mapClusterByAsset.set(c.assetIds[ai], c);
3234
+ }
3235
+ for (var ni = 0; ni < MAP.assets.length; ni++) mapAssetIndex.set(MAP.assets[ni].id, MAP.assets[ni]);
3236
+
3237
+ var mapCanvas = document.getElementById('hexmap');
3238
+ var mapCtx = mapCanvas.getContext('2d');
3239
+ var mapWrap = document.getElementById('map-wrap');
3240
+ var mW = 0, mH = 0;
3241
+ var mvx = 0, mvy = 0, mScale = 1;
3242
+ var mDetailLevel = 2, mShowLabels = true, mShowQuality = false;
3243
+ var mConnectMode = false, mConnectFirst = null;
3244
+ var mHoveredId = null, mSelectedId = null;
3245
+ var mSearchQuery = '';
3246
+ var mLocalConns = MAP.connections.slice();
3247
+
3248
+ function setMapSearch(q) { mSearchQuery = q; drawMap(); }
3249
+
3250
+ function resizeMap() {
3251
+ var dpr = window.devicePixelRatio || 1;
3252
+ mW = mapWrap.clientWidth; mH = mapWrap.clientHeight;
3253
+ mapCanvas.width = mW * dpr; mapCanvas.height = mH * dpr;
3254
+ mapCanvas.style.width = mW + 'px'; mapCanvas.style.height = mH + 'px';
3255
+ mapCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
3256
+ drawMap();
3257
+ }
3258
+ window.addEventListener('resize', function() { if (currentTab === 'map') resizeMap(); });
3259
+
3260
+ function mHtp_x(q, r) { return MAP_HEX * (1.5 * q); }
3261
+ function mHtp_y(q, r) { return MAP_HEX * (Math.sqrt(3) / 2 * q + Math.sqrt(3) * r); }
3262
+ function mW2s(wx, wy) { return { x: wx * mScale + mvx, y: wy * mScale + mvy }; }
3263
+ function mS2w(sx, sy) { return { x: (sx - mvx) / mScale, y: (sy - mvy) / mScale }; }
3264
+
3265
+ function mapFitToView() {
3266
+ if (MAP_EMPTY || MAP.assets.length === 0) { mvx = 0; mvy = 0; mScale = 1; return; }
3267
+ var mnx = Infinity, mny = Infinity, mxx = -Infinity, mxy = -Infinity;
3268
+ for (var i = 0; i < MAP.assets.length; i++) {
3269
+ var a = MAP.assets[i], px = mHtp_x(a.q, a.r), py = mHtp_y(a.q, a.r);
3270
+ if (px < mnx) mnx = px; if (py < mny) mny = py; if (px > mxx) mxx = px; if (py > mxy) mxy = py;
3271
+ }
3272
+ var pw = mxx - mnx + MAP_HEX * 4, ph = mxy - mny + MAP_HEX * 4;
3273
+ mScale = Math.min(mW / pw, mH / ph, 2) * 0.85;
3274
+ mvx = mW / 2 - ((mnx + mxx) / 2) * mScale;
3275
+ mvy = mH / 2 - ((mny + mxy) / 2) * mScale;
3276
+ }
3277
+
3278
+ function mHexPath(cx, cy, r) {
3279
+ mapCtx.beginPath();
3280
+ for (var i = 0; i < 6; i++) {
3281
+ var angle = Math.PI / 180 * (60 * i);
3282
+ var x = cx + r * Math.cos(angle), y = cy + r * Math.sin(angle);
3283
+ i === 0 ? mapCtx.moveTo(x, y) : mapCtx.lineTo(x, y);
3284
+ }
3285
+ mapCtx.closePath();
3286
+ }
3287
+
3288
+ function mShadeV(hex, amt) {
3289
+ if (!hex || hex.length < 7) return hex;
3290
+ var n = parseInt(hex.replace('#', ''), 16);
3291
+ var r = Math.min(255, (n >> 16) + amt), g = Math.min(255, ((n >> 8) & 0xff) + amt), b = Math.min(255, (n & 0xff) + amt);
3292
+ return '#' + r.toString(16).padStart(2, '0') + g.toString(16).padStart(2, '0') + b.toString(16).padStart(2, '0');
3293
+ }
3294
+
3295
+ function mGetSearchMatches() {
3296
+ if (!mSearchQuery) return new Set();
3297
+ var q = mSearchQuery.toLowerCase(), m = new Set();
3298
+ for (var i = 0; i < MAP.assets.length; i++) {
3299
+ var a = MAP.assets[i];
3300
+ if (a.name.toLowerCase().includes(q) || (a.domain && a.domain.toLowerCase().includes(q)) ||
3301
+ (a.subDomain && a.subDomain.toLowerCase().includes(q))) m.add(a.id);
3302
+ }
3303
+ return m;
3304
+ }
3305
+
3306
+ function mDrawPill(x, y, text, color, fontSize) {
3307
+ if (!text) return;
3308
+ mapCtx.save();
3309
+ mapCtx.font = '600 ' + fontSize + 'px -apple-system,sans-serif';
3310
+ var tw = mapCtx.measureText(text).width;
3311
+ var ph = fontSize + 8, pw = tw + 20;
3312
+ mapCtx.beginPath();
3313
+ if (mapCtx.roundRect) mapCtx.roundRect(x - pw / 2, y - ph / 2, pw, ph, ph / 2);
3314
+ else mapCtx.rect(x - pw / 2, y - ph / 2, pw, ph);
3315
+ mapCtx.fillStyle = isDark ? 'rgba(30,41,59,0.9)' : 'rgba(255,255,255,0.92)';
3316
+ mapCtx.shadowColor = 'rgba(0,0,0,0.15)'; mapCtx.shadowBlur = 6;
3317
+ mapCtx.fill(); mapCtx.shadowBlur = 0;
3318
+ mapCtx.fillStyle = isDark ? '#e2e8f0' : '#0f172a';
3319
+ mapCtx.textAlign = 'center'; mapCtx.textBaseline = 'middle';
3320
+ mapCtx.fillText(text, x, y);
3321
+ mapCtx.restore();
3322
+ }
3323
+
3324
+ function drawMap() {
3325
+ mapCtx.clearRect(0, 0, mW, mH);
3326
+ var bg = getComputedStyle(document.documentElement).getPropertyValue('--bg-base').trim();
3327
+ mapCtx.fillStyle = bg || (isDark ? '#0f172a' : '#f8fafc');
3328
+ mapCtx.fillRect(0, 0, mW, mH);
3329
+ if (MAP_EMPTY) return;
3330
+
3331
+ var size = MAP_HEX * mScale;
3332
+ var matchedIds = mGetSearchMatches();
3333
+ var hasSearch = mSearchQuery.length > 0;
3334
+
3335
+ // Connections
3336
+ mapCtx.save();
3337
+ mapCtx.strokeStyle = isDark ? 'rgba(148,163,184,0.35)' : 'rgba(100,116,139,0.25)';
3338
+ mapCtx.lineWidth = 1.5; mapCtx.setLineDash([4, 4]);
3339
+ for (var ci = 0; ci < mLocalConns.length; ci++) {
3340
+ var conn = mLocalConns[ci];
3341
+ var src = mapAssetIndex.get(conn.sourceAssetId), tgt = mapAssetIndex.get(conn.targetAssetId);
3342
+ if (!src || !tgt) continue;
3343
+ var sp = mW2s(mHtp_x(src.q, src.r), mHtp_y(src.q, src.r));
3344
+ var tp = mW2s(mHtp_x(tgt.q, tgt.r), mHtp_y(tgt.q, tgt.r));
3345
+ mapCtx.beginPath(); mapCtx.moveTo(sp.x, sp.y); mapCtx.lineTo(tp.x, tp.y); mapCtx.stroke();
3346
+ }
3347
+ mapCtx.setLineDash([]); mapCtx.restore();
3348
+
3349
+ // Hexagons per cluster
3350
+ for (var cli = 0; cli < MAP.clusters.length; cli++) {
3351
+ var cluster = MAP.clusters[cli];
3352
+ var baseColor = cluster.color;
3353
+ var clusterAssets = cluster.assetIds.map(function(id) { return mapAssetIndex.get(id); }).filter(Boolean);
3354
+ var isClusterMatch = !hasSearch || clusterAssets.some(function(a) { return matchedIds.has(a.id); });
3355
+ var clusterDim = hasSearch && !isClusterMatch;
3356
+
3357
+ for (var ai = 0; ai < clusterAssets.length; ai++) {
3358
+ var asset = clusterAssets[ai];
3359
+ var wx = mHtp_x(asset.q, asset.r), wy = mHtp_y(asset.q, asset.r);
3360
+ var s = mW2s(wx, wy), cx = s.x, cy = s.y;
3361
+ if (cx + size < 0 || cx - size > mW || cy + size < 0 || cy - size > mH) continue;
3362
+
3363
+ var shade = ai % 3 === 0 ? 18 : ai % 3 === 1 ? 8 : 0;
3364
+ var fillColor = mShadeV(baseColor, shade);
3365
+ if (mShowQuality && asset.qualityScore !== null && asset.qualityScore !== undefined) {
3366
+ if (asset.qualityScore < 40) fillColor = '#ef4444';
3367
+ else if (asset.qualityScore < 70) fillColor = '#f97316';
3368
+ }
3369
+
3370
+ var alpha = clusterDim ? 0.18 : 1;
3371
+ var isHov = asset.id === mHoveredId, isSel = asset.id === mSelectedId, isCF = asset.id === mConnectFirst;
3372
+
3373
+ mapCtx.save(); mapCtx.globalAlpha = alpha;
3374
+ mHexPath(cx, cy, size * 0.92);
3375
+ if (isDark && (isHov || isSel || isCF)) { mapCtx.shadowColor = fillColor; mapCtx.shadowBlur = isSel ? 16 : 8; }
3376
+ mapCtx.fillStyle = fillColor; mapCtx.fill();
3377
+ if (isSel || isCF) { mapCtx.strokeStyle = isCF ? '#f59e0b' : '#fff'; mapCtx.lineWidth = 2.5; mapCtx.stroke(); }
3378
+ else if (isHov) { mapCtx.strokeStyle = isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.2)'; mapCtx.lineWidth = 1.5; mapCtx.stroke(); }
3379
+ else { mapCtx.strokeStyle = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.4)'; mapCtx.lineWidth = 1; mapCtx.stroke(); }
3380
+ mapCtx.restore();
3381
+
3382
+ if (mShowQuality && asset.qualityScore !== null && asset.qualityScore !== undefined && size > 8 && asset.qualityScore < 70) {
3383
+ mapCtx.beginPath(); mapCtx.arc(cx + size * 0.4, cy - size * 0.4, Math.max(3, size * 0.14), 0, Math.PI * 2);
3384
+ mapCtx.fillStyle = asset.qualityScore < 40 ? '#ef4444' : '#f97316'; mapCtx.fill();
3385
+ }
3386
+
3387
+ var showAssetLabel = mShowLabels && !clusterDim && ((mDetailLevel >= 4) || (mDetailLevel === 3 && mScale >= 0.8));
3388
+ if (showAssetLabel && size > 14) {
3389
+ var label = asset.name.length > 12 ? asset.name.substring(0, 11) + '...' : asset.name;
3390
+ mapCtx.save();
3391
+ mapCtx.font = Math.max(8, Math.min(11, size * 0.38)) + 'px -apple-system,sans-serif';
3392
+ mapCtx.fillStyle = isDark ? 'rgba(255,255,255,0.85)' : 'rgba(255,255,255,0.9)';
3393
+ mapCtx.textAlign = 'center'; mapCtx.textBaseline = 'middle';
3394
+ mapCtx.fillText(label, cx, cy); mapCtx.restore();
3395
+ }
3396
+ }
3397
+ }
3398
+
3399
+ // Cluster labels
3400
+ if (mShowLabels && mDetailLevel >= 1) {
3401
+ for (var cli2 = 0; cli2 < MAP.clusters.length; cli2++) {
3402
+ var cl = MAP.clusters[cli2];
3403
+ if (cl.assetIds.length === 0) continue;
3404
+ if (hasSearch && !cl.assetIds.some(function(id) { return matchedIds.has(id); })) continue;
3405
+ var sc = mW2s(cl.centroid.x, cl.centroid.y);
3406
+ mDrawPill(sc.x, sc.y - size * 1.2, cl.label, cl.color, 14);
3407
+ }
3408
+ }
3409
+
3410
+ // Sub-domain labels
3411
+ if (mShowLabels && mDetailLevel >= 2) {
3412
+ var subGroups = new Map();
3413
+ for (var si = 0; si < MAP.assets.length; si++) {
3414
+ var sa = MAP.assets[si];
3415
+ if (!sa.subDomain) continue;
3416
+ var key = sa.domain + '|' + sa.subDomain;
3417
+ if (!subGroups.has(key)) subGroups.set(key, []);
3418
+ subGroups.get(key).push(sa);
3419
+ }
3420
+ subGroups.forEach(function(group) {
3421
+ var sx = 0, sy = 0;
3422
+ 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); }
3423
+ var cxs = sx / group.length, cys = sy / group.length;
3424
+ var spt = mW2s(cxs, cys);
3425
+ mDrawPill(spt.x, spt.y + size * 1.5, group[0].subDomain, '#64748b', 11);
3426
+ });
3427
+ }
3428
+ }
3429
+
3430
+ // \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
3431
+ function mGetAssetAt(sx, sy) {
3432
+ var w = mS2w(sx, sy);
3433
+ for (var i = 0; i < MAP.assets.length; i++) {
3434
+ var a = MAP.assets[i], wx = mHtp_x(a.q, a.r), wy = mHtp_y(a.q, a.r);
3435
+ var dx = Math.abs(w.x - wx), dy = Math.abs(w.y - wy);
3436
+ if (dx > MAP_HEX || dy > MAP_HEX) continue;
3437
+ if (dx * dx + dy * dy < MAP_HEX * MAP_HEX) return a;
3438
+ }
3439
+ return null;
3440
+ }
3441
+
3442
+ // \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
3443
+ var mDragging = false, mLastMX = 0, mLastMY = 0;
3444
+ mapWrap.addEventListener('mousedown', function(e) {
3445
+ if (e.button !== 0) return;
3446
+ mDragging = true; mLastMX = e.clientX; mLastMY = e.clientY;
3447
+ mapWrap.classList.add('dragging');
2955
3448
  });
2956
- document.getElementById('btn-quality').addEventListener('click', () => {
2957
- showQuality = !showQuality;
2958
- document.getElementById('btn-quality').classList.toggle('active', showQuality);
2959
- draw();
3449
+ window.addEventListener('mouseup', function() { mDragging = false; mapWrap.classList.remove('dragging'); });
3450
+ window.addEventListener('mousemove', function(e) {
3451
+ if (currentTab !== 'map') return;
3452
+ if (mDragging) {
3453
+ mvx += e.clientX - mLastMX; mvy += e.clientY - mLastMY;
3454
+ mLastMX = e.clientX; mLastMY = e.clientY; drawMap(); return;
3455
+ }
3456
+ var rect = mapWrap.getBoundingClientRect();
3457
+ var sx = e.clientX - rect.left, sy = e.clientY - rect.top;
3458
+ var asset = mGetAssetAt(sx, sy);
3459
+ var newId = asset ? asset.id : null;
3460
+ if (newId !== mHoveredId) { mHoveredId = newId; drawMap(); }
3461
+ var tt = document.getElementById('map-tooltip');
3462
+ if (asset) {
3463
+ document.getElementById('mtt-name').textContent = asset.name;
3464
+ document.getElementById('mtt-domain').textContent = asset.domain + (asset.subDomain ? ' > ' + asset.subDomain : '');
3465
+ document.getElementById('mtt-quality').textContent = asset.qualityScore !== null ? 'Quality: ' + asset.qualityScore + '/100' : '';
3466
+ tt.style.display = 'block'; tt.style.left = (e.clientX + 12) + 'px'; tt.style.top = (e.clientY - 8) + 'px';
3467
+ } else { tt.style.display = 'none'; }
2960
3468
  });
2961
3469
 
2962
- function toggleConnectMode() {
2963
- connectMode = !connectMode;
2964
- connectFirst = null;
2965
- document.getElementById('connect-btn').classList.toggle('active', connectMode);
2966
- wrap.classList.toggle('connecting', connectMode);
2967
- document.getElementById('connect-hint').style.display = connectMode ? 'block' : 'none';
2968
- draw();
3470
+ mapWrap.addEventListener('click', function(e) {
3471
+ var rect = mapWrap.getBoundingClientRect();
3472
+ var sx = e.clientX - rect.left, sy = e.clientY - rect.top;
3473
+ var asset = mGetAssetAt(sx, sy);
3474
+ if (mConnectMode) {
3475
+ if (!asset) return;
3476
+ if (!mConnectFirst) { mConnectFirst = asset.id; drawMap(); }
3477
+ else if (mConnectFirst !== asset.id) {
3478
+ mLocalConns.push({ id: crypto.randomUUID(), sourceAssetId: mConnectFirst, targetAssetId: asset.id, type: 'connection' });
3479
+ mConnectFirst = null; drawMap();
3480
+ }
3481
+ return;
3482
+ }
3483
+ if (asset) { mSelectedId = asset.id; mShowDetail(asset); }
3484
+ else { mSelectedId = null; document.getElementById('map-detail').classList.remove('open'); }
3485
+ drawMap();
3486
+ });
3487
+
3488
+ mapWrap.addEventListener('wheel', function(e) {
3489
+ e.preventDefault();
3490
+ var rect = mapWrap.getBoundingClientRect();
3491
+ mApplyZoom(e.deltaY < 0 ? 1.12 : 1 / 1.12, e.clientX - rect.left, e.clientY - rect.top);
3492
+ }, { passive: false });
3493
+
3494
+ function mApplyZoom(factor, sx, sy) {
3495
+ var ns = Math.max(0.05, Math.min(8, mScale * factor));
3496
+ var wx = (sx - mvx) / mScale, wy = (sy - mvy) / mScale;
3497
+ mScale = ns; mvx = sx - wx * mScale; mvy = sy - wy * mScale;
3498
+ document.getElementById('map-zoom-pct').textContent = Math.round(mScale * 100) + '%';
3499
+ drawMap();
2969
3500
  }
2970
- document.getElementById('connect-btn').addEventListener('click', toggleConnectMode);
2971
3501
 
2972
- document.getElementById('theme-btn').addEventListener('click', () => {
2973
- isDark = !isDark;
2974
- document.body.classList.toggle('dark', isDark);
2975
- document.getElementById('theme-btn').textContent = isDark ? '\u2600\uFE0F' : '\u{1F319}';
2976
- draw();
3502
+ document.getElementById('mz-in').addEventListener('click', function() { mApplyZoom(1.25, mW / 2, mH / 2); });
3503
+ document.getElementById('mz-out').addEventListener('click', function() { mApplyZoom(1 / 1.25, mW / 2, mH / 2); });
3504
+
3505
+ // \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
3506
+ function mEsc(s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
3507
+ function mRenderQ(s) {
3508
+ var c = s >= 70 ? '#22c55e' : s >= 40 ? '#f97316' : '#ef4444';
3509
+ return s + '/100 <div class="quality-bar"><div class="quality-fill" style="width:' + s + '%;background:' + c + '"></div></div>';
3510
+ }
3511
+ function mShowDetail(asset) {
3512
+ document.getElementById('md-name').textContent = asset.name;
3513
+ var body = document.getElementById('md-body');
3514
+ var rows = [['Domain', asset.domain], ['Sub-domain', asset.subDomain],
3515
+ ['Quality', asset.qualityScore !== null ? mRenderQ(asset.qualityScore) : null]
3516
+ ].concat(Object.entries(asset.metadata || {}).slice(0, 8).map(function(kv) { return [kv[0], String(kv[1])]; }))
3517
+ .filter(function(r) { return r[1] !== null && r[1] !== undefined && r[1] !== ''; });
3518
+ body.innerHTML = rows.map(function(r) {
3519
+ return '<div class="meta-row"><div class="meta-label">' + mEsc(String(r[0])) + '</div><div class="meta-value">' + r[1] + '</div></div>';
3520
+ }).join('');
3521
+ var related = mLocalConns.filter(function(cn) { return cn.sourceAssetId === asset.id || cn.targetAssetId === asset.id; });
3522
+ if (related.length > 0) {
3523
+ body.innerHTML += '<div class="meta-row"><div class="meta-label">Connections (' + related.length + ')</div><div>' +
3524
+ related.map(function(cn) {
3525
+ var oid = cn.sourceAssetId === asset.id ? cn.targetAssetId : cn.sourceAssetId;
3526
+ var o = mapAssetIndex.get(oid);
3527
+ return '<div class="meta-value" style="margin-top:4px;font-size:12px">' + (o ? mEsc(o.name) : oid) + '</div>';
3528
+ }).join('') + '</div></div>';
3529
+ }
3530
+ document.getElementById('map-detail').classList.add('open');
3531
+ }
3532
+ document.getElementById('md-close').addEventListener('click', function() {
3533
+ document.getElementById('map-detail').classList.remove('open'); mSelectedId = null; drawMap();
2977
3534
  });
2978
3535
 
2979
- // \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
2980
- document.getElementById('search-input').addEventListener('input', e => {
2981
- searchQuery = e.target.value.trim();
2982
- draw();
3536
+ // \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
3537
+ document.getElementById('btn-labels').addEventListener('click', function() {
3538
+ mShowLabels = !mShowLabels; this.classList.toggle('active', mShowLabels); drawMap();
3539
+ });
3540
+ document.getElementById('btn-quality').addEventListener('click', function() {
3541
+ mShowQuality = !mShowQuality; this.classList.toggle('active', mShowQuality); drawMap();
3542
+ });
3543
+ document.getElementById('btn-connect').addEventListener('click', function() {
3544
+ mConnectMode = !mConnectMode; mConnectFirst = null;
3545
+ this.classList.toggle('active', mConnectMode);
3546
+ mapWrap.classList.toggle('connecting', mConnectMode);
3547
+ document.getElementById('map-connect-hint').style.display = mConnectMode ? 'block' : 'none'; drawMap();
2983
3548
  });
2984
3549
 
2985
- // \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
2986
- resize();
2987
- fitToView();
2988
- document.getElementById('zoom-pct').textContent = Math.round(scale*100)+'%';
2989
- draw();
3550
+ // Map keyboard
3551
+ mapWrap.addEventListener('keydown', function(e) {
3552
+ if (e.key === 'ArrowLeft') { mvx += 40; drawMap(); }
3553
+ else if (e.key === 'ArrowRight') { mvx -= 40; drawMap(); }
3554
+ else if (e.key === 'ArrowUp') { mvy += 40; drawMap(); }
3555
+ else if (e.key === 'ArrowDown') { mvy -= 40; drawMap(); }
3556
+ else if (e.key === '+' || e.key === '=') mApplyZoom(1.2, mW / 2, mH / 2);
3557
+ else if (e.key === '-') mApplyZoom(1 / 1.2, mW / 2, mH / 2);
3558
+ else if (e.key === 'Escape') {
3559
+ mSelectedId = null; document.getElementById('map-detail').classList.remove('open');
3560
+ 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'); }
3561
+ drawMap();
3562
+ }
3563
+ });
2990
3564
 
2991
- })();
3565
+ // Map init
3566
+ resizeMap(); mapFitToView();
3567
+ document.getElementById('map-zoom-pct').textContent = Math.round(mScale * 100) + '%';
3568
+ drawMap();
3569
+
3570
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3571
+ // TOPOLOGY VIEW (lazy init)
3572
+ // \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
3573
+ var TOPO = ${graphData};
3574
+
3575
+ var TYPE_COLORS = {
3576
+ host:'#4a9eff',database_server:'#ff6b6b',database:'#ff8c42',
3577
+ web_service:'#6bcb77',api_endpoint:'#4d96ff',cache_server:'#ffd93d',
3578
+ message_broker:'#c77dff',queue:'#e0aaff',topic:'#9d4edd',
3579
+ container:'#48cae4',pod:'#00b4d8',k8s_cluster:'#0077b6',
3580
+ config_file:'#adb5bd',saas_tool:'#c084fc',table:'#f97316',unknown:'#6c757d'
3581
+ };
3582
+ var LAYER_COLORS = { saas:'#c084fc',web:'#6bcb77',data:'#ff6b6b',messaging:'#c77dff',infra:'#4a9eff',config:'#adb5bd',other:'#6c757d' };
3583
+ var LAYER_NAMES = { saas:'SaaS Tools',web:'Web / API',data:'Data Layer',messaging:'Messaging',infra:'Infrastructure',config:'Config',other:'Other' };
3584
+
3585
+ var topoSelectedId = null;
3586
+
3587
+ function setTopoSearch(q) {
3588
+ var el = document.getElementById('topo-search');
3589
+ if (el) { el.value = q; buildTopoList(q); }
3590
+ }
3591
+
3592
+ function buildTopoList(filter) {
3593
+ var listEl = document.getElementById('topo-list');
3594
+ var q = (filter || '').toLowerCase();
3595
+ listEl.innerHTML = '';
3596
+ var sorted = TOPO.nodes.slice().sort(function(a, b) { return a.name.localeCompare(b.name); });
3597
+ for (var i = 0; i < sorted.length; i++) {
3598
+ var d = sorted[i];
3599
+ if (q && !d.name.toLowerCase().includes(q) && !d.type.includes(q) && !d.id.toLowerCase().includes(q)) continue;
3600
+ var item = document.createElement('div');
3601
+ item.className = 'topo-item' + (d.id === topoSelectedId ? ' active' : '');
3602
+ item.dataset.id = d.id;
3603
+ var color = TYPE_COLORS[d.type] || '#aaa';
3604
+ item.innerHTML = '<span class="topo-dot" style="background:' + color + '"></span>' +
3605
+ '<span class="topo-name" title="' + d.id + '">' + d.name + '</span>' +
3606
+ '<span class="topo-type">' + d.type.replace(/_/g, ' ') + '</span>';
3607
+ (function(dd) { item.onclick = function() { selectTopoNode(dd); focusTopoNode(dd); }; })(d);
3608
+ listEl.appendChild(item);
3609
+ }
3610
+ }
3611
+
3612
+ document.getElementById('topo-search').addEventListener('input', function(e) { buildTopoList(e.target.value); });
3613
+
3614
+ var topoSidebar = document.getElementById('topo-sidebar');
3615
+
3616
+ function selectTopoNode(d) {
3617
+ topoSelectedId = d.id;
3618
+ buildTopoList(document.getElementById('topo-search').value);
3619
+ showTopoNode(d);
3620
+ if (typeof d3 !== 'undefined') d3.selectAll('.node-hex').classed('selected', function(nd) { return nd.id === d.id; });
3621
+ }
3622
+
3623
+ function showTopoNode(d) {
3624
+ var c = TYPE_COLORS[d.type] || '#aaa';
3625
+ var confPct = Math.round(d.confidence * 100);
3626
+ var tags = (d.tags || []).map(function(t) { return '<span class="tag">' + t + '</span>'; }).join('');
3627
+ var metaRows = Object.entries(d.metadata || {})
3628
+ .filter(function(kv) { return kv[1] !== null && kv[1] !== undefined && String(kv[1]).length > 0; })
3629
+ .map(function(kv) { return '<tr><td>' + kv[0] + '</td><td>' + JSON.stringify(kv[1]) + '</td></tr>'; }).join('');
3630
+ var related = TOPO.links.filter(function(l) {
3631
+ return (l.source.id || l.source) === d.id || (l.target.id || l.target) === d.id;
3632
+ });
3633
+ var edgeItems = related.map(function(l) {
3634
+ var isOut = (l.source.id || l.source) === d.id;
3635
+ var other = isOut ? (l.target.id || l.target) : (l.source.id || l.source);
3636
+ return '<div class="edge-item">' + (isOut ? '\\u2192' : '\\u2190') + ' <span>' + other + '</span> <small>[' + l.relationship + ']</small></div>';
3637
+ }).join('');
3638
+
3639
+ topoSidebar.innerHTML =
3640
+ '<h2>' + d.name + '</h2>' +
3641
+ '<table class="meta-table">' +
3642
+ '<tr><td>ID</td><td style="font-size:10px;word-break:break-all">' + d.id + '</td></tr>' +
3643
+ '<tr><td>Type</td><td><span style="color:' + c + '">' + d.type + '</span></td></tr>' +
3644
+ '<tr><td>Layer</td><td>' + d.layer + '</td></tr>' +
3645
+ '<tr><td>Confidence</td><td>' + confPct + '% <div class="conf-bar"><div class="conf-fill" style="width:' + confPct + '%;background:' + c + '"></div></div></td></tr>' +
3646
+ '<tr><td>Via</td><td>' + (d.discoveredVia || '\\u2014') + '</td></tr>' +
3647
+ '<tr><td>Timestamp</td><td>' + (d.discoveredAt ? d.discoveredAt.substring(0, 19).replace('T', ' ') : '\\u2014') + '</td></tr>' +
3648
+ (tags ? '<tr><td>Tags</td><td>' + tags + '</td></tr>' : '') +
3649
+ metaRows + '</table>' +
3650
+ (related.length > 0 ? '<div class="edges-list"><strong>Connections (' + related.length + '):</strong>' + edgeItems + '</div>' : '') +
3651
+ '<div style="margin-top:14px"><button class="export-btn" style="width:100%" onclick="deleteTopoNode(\\'' + d.id.replace(/'/g, "\\\\'") + '\\')">Delete node</button></div>';
3652
+ }
3653
+
3654
+ function deleteTopoNode(id) {
3655
+ var idx = TOPO.nodes.findIndex(function(n) { return n.id === id; });
3656
+ if (idx === -1) return;
3657
+ TOPO.nodes.splice(idx, 1);
3658
+ TOPO.links = TOPO.links.filter(function(l) {
3659
+ return (l.source.id || l.source) !== id && (l.target.id || l.target) !== id;
3660
+ });
3661
+ topoSelectedId = null;
3662
+ topoSidebar.innerHTML = '<h2>Infrastructure Map</h2><p class="hint">Node deleted.</p>';
3663
+ if (typeof rebuildTopoGraph === 'function') rebuildTopoGraph();
3664
+ buildTopoList(document.getElementById('topo-search').value);
3665
+ }
3666
+
3667
+ function initTopology() {
3668
+ if (typeof d3 === 'undefined') return;
3669
+
3670
+ var svgEl = d3.select('#topo-graph svg');
3671
+ var graphDiv = document.getElementById('topo-graph');
3672
+ var gW = function() { return graphDiv.clientWidth; };
3673
+ var gH = function() { return graphDiv.clientHeight; };
3674
+ var g = svgEl.append('g');
3675
+
3676
+ svgEl.append('defs').append('marker')
3677
+ .attr('id', 'arrow').attr('viewBox', '0 0 10 6')
3678
+ .attr('refX', 10).attr('refY', 3)
3679
+ .attr('markerWidth', 8).attr('markerHeight', 6)
3680
+ .attr('orient', 'auto')
3681
+ .append('path').attr('d', 'M0,0 L10,3 L0,6 Z').attr('fill', '#555');
3682
+
3683
+ var currentZoom = 1;
3684
+ var zoomBehavior = d3.zoom().scaleExtent([0.08, 6]).on('zoom', function(e) {
3685
+ g.attr('transform', e.transform); currentZoom = e.transform.k; updateTopoLOD(currentZoom);
3686
+ });
3687
+ svgEl.call(zoomBehavior);
3688
+
3689
+ // Layer filters
3690
+ var layers = Array.from(new Set(TOPO.nodes.map(function(d) { return d.layer; })));
3691
+ var layerVisible = {};
3692
+ layers.forEach(function(l) { layerVisible[l] = true; });
3693
+
3694
+ var toolbarEl = document.getElementById('topo-toolbar');
3695
+ layers.forEach(function(layer) {
3696
+ var btn = document.createElement('button');
3697
+ btn.className = 'filter-btn';
3698
+ btn.innerHTML = '<span class="filter-dot" style="background:' + (LAYER_COLORS[layer] || '#666') + '"></span>' + (LAYER_NAMES[layer] || layer);
3699
+ btn.onclick = function() { layerVisible[layer] = !layerVisible[layer]; btn.classList.toggle('off', !layerVisible[layer]); updateTopoVisibility(); };
3700
+ toolbarEl.appendChild(btn);
3701
+ });
3702
+
3703
+ // JGF export button
3704
+ var jgfBtn = document.createElement('button');
3705
+ jgfBtn.className = 'export-btn'; jgfBtn.textContent = '\\u2193 JGF'; jgfBtn.title = 'Export JSON Graph Format';
3706
+ jgfBtn.onclick = function() {
3707
+ var jgf = { graph: { directed: true, type: 'cartography', label: 'Infrastructure Map',
3708
+ metadata: { exportedAt: new Date().toISOString() },
3709
+ 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 } }]; })),
3710
+ 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 } }; })
3711
+ }};
3712
+ var blob = new Blob([JSON.stringify(jgf, null, 2)], { type: 'application/json' });
3713
+ var url = URL.createObjectURL(blob);
3714
+ var a = document.createElement('a'); a.href = url; a.download = 'cartography-graph.jgf.json'; a.click();
3715
+ URL.revokeObjectURL(url);
3716
+ };
3717
+ toolbarEl.appendChild(jgfBtn);
3718
+
3719
+ // Hex helpers
3720
+ var T_HEX = { saas_tool: 16, host: 18, database_server: 18, k8s_cluster: 20, default: 14 };
3721
+ function tHexSize(d) { return T_HEX[d.type] || T_HEX.default; }
3722
+ function tHexPath(size) {
3723
+ var pts = [];
3724
+ for (var i = 0; i < 6; i++) {
3725
+ var angle = (Math.PI / 3) * i - Math.PI / 6;
3726
+ pts.push([size * Math.cos(angle), size * Math.sin(angle)]);
3727
+ }
3728
+ return 'M' + pts.map(function(p) { return p.join(','); }).join('L') + 'Z';
3729
+ }
3730
+
3731
+ // Cluster force
3732
+ function clusterForce(alpha) {
3733
+ var centroids = {}, counts = {};
3734
+ TOPO.nodes.forEach(function(d) {
3735
+ if (!centroids[d.layer]) { centroids[d.layer] = { x: 0, y: 0 }; counts[d.layer] = 0; }
3736
+ centroids[d.layer].x += d.x || 0; centroids[d.layer].y += d.y || 0; counts[d.layer]++;
3737
+ });
3738
+ for (var l in centroids) { centroids[l].x /= counts[l]; centroids[l].y /= counts[l]; }
3739
+ var strength = alpha * 0.15;
3740
+ TOPO.nodes.forEach(function(d) {
3741
+ var cn = centroids[d.layer];
3742
+ if (cn) { d.vx += (cn.x - d.x) * strength; d.vy += (cn.y - d.y) * strength; }
3743
+ });
3744
+ }
3745
+
3746
+ // Hulls
3747
+ var hullGroup = g.append('g').attr('class', 'hulls');
3748
+ var hullPaths = {}, hullLabels = {};
3749
+ layers.forEach(function(layer) {
3750
+ hullPaths[layer] = hullGroup.append('path').attr('class', 'hull')
3751
+ .attr('fill', LAYER_COLORS[layer] || '#666').attr('stroke', LAYER_COLORS[layer] || '#666');
3752
+ hullLabels[layer] = hullGroup.append('text').attr('class', 'hull-label')
3753
+ .attr('fill', LAYER_COLORS[layer] || '#666').text(LAYER_NAMES[layer] || layer);
3754
+ });
3755
+
3756
+ function updateHulls() {
3757
+ layers.forEach(function(layer) {
3758
+ if (!layerVisible[layer]) { hullPaths[layer].attr('d', null); hullLabels[layer].attr('x', -9999); return; }
3759
+ var pts = TOPO.nodes.filter(function(d) { return d.layer === layer && layerVisible[d.layer]; }).map(function(d) { return [d.x, d.y]; });
3760
+ if (pts.length < 3) {
3761
+ hullPaths[layer].attr('d', null);
3762
+ if (pts.length > 0) hullLabels[layer].attr('x', pts[0][0]).attr('y', pts[0][1] - 30);
3763
+ else hullLabels[layer].attr('x', -9999);
3764
+ return;
3765
+ }
3766
+ var hull = d3.polygonHull(pts);
3767
+ if (!hull) { hullPaths[layer].attr('d', null); return; }
3768
+ var cx = d3.mean(hull, function(p) { return p[0]; });
3769
+ var cy = d3.mean(hull, function(p) { return p[1]; });
3770
+ var padded = hull.map(function(p) {
3771
+ var dx = p[0] - cx, dy = p[1] - cy;
3772
+ var len = Math.sqrt(dx * dx + dy * dy) || 1;
3773
+ return [p[0] + dx / len * 40, p[1] + dy / len * 40];
3774
+ });
3775
+ hullPaths[layer].attr('d', 'M' + padded.join('L') + 'Z');
3776
+ hullLabels[layer].attr('x', cx).attr('y', cy - d3.max(hull, function(p) { return Math.abs(p[1] - cy); }) - 30);
3777
+ });
3778
+ }
3779
+
3780
+ // Graph
3781
+ var linkSel, linkLabelSel, nodeSel, nodeLabelSel, sim;
3782
+ var linkGroup = g.append('g');
3783
+ var nodeGroup = g.append('g');
3784
+
3785
+ function focusTopoNode(d) {
3786
+ if (!d.x || !d.y) return;
3787
+ var w = gW(), h = gH();
3788
+ svgEl.transition().duration(500).call(
3789
+ zoomBehavior.transform,
3790
+ d3.zoomIdentity.translate(w / 2, h / 2).scale(Math.min(3, currentZoom < 1 ? 1.5 : currentZoom)).translate(-d.x, -d.y)
3791
+ );
3792
+ }
3793
+ window.focusTopoNode = focusTopoNode;
3794
+
3795
+ function rebuildTopoGraph() {
3796
+ if (sim) sim.stop();
3797
+
3798
+ linkSel = linkGroup.selectAll('line').data(TOPO.links, function(d) { return (d.source.id || d.source) + '>' + (d.target.id || d.target); });
3799
+ linkSel.exit().remove();
3800
+ var linkEnter = linkSel.enter().append('line').attr('class', 'link');
3801
+ linkSel = linkEnter.merge(linkSel)
3802
+ .attr('stroke', function(d) { return d.confidence < 0.6 ? '#2a2e35' : '#3d434b'; })
3803
+ .attr('stroke-dasharray', function(d) { return d.confidence < 0.6 ? '4 3' : null; })
3804
+ .attr('stroke-width', function(d) { return d.confidence < 0.6 ? 0.8 : 1.2; })
3805
+ .attr('marker-end', 'url(#arrow)');
3806
+ linkSel.select('title').remove();
3807
+ linkSel.append('title').text(function(d) { return d.relationship + ' (' + Math.round(d.confidence * 100) + '%)\\n' + (d.evidence || ''); });
3808
+
3809
+ linkLabelSel = linkGroup.selectAll('text').data(TOPO.links, function(d) { return (d.source.id || d.source) + '>' + (d.target.id || d.target); });
3810
+ linkLabelSel.exit().remove();
3811
+ linkLabelSel = linkLabelSel.enter().append('text').attr('class', 'link-label').merge(linkLabelSel).text(function(d) { return d.relationship; });
3812
+
3813
+ nodeSel = nodeGroup.selectAll('g').data(TOPO.nodes, function(d) { return d.id; });
3814
+ nodeSel.exit().remove();
3815
+ var nodeEnter = nodeSel.enter().append('g')
3816
+ .call(d3.drag()
3817
+ .on('start', function(e, d) { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
3818
+ .on('drag', function(e, d) { d.fx = e.x; d.fy = e.y; })
3819
+ .on('end', function(e, d) { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; })
3820
+ )
3821
+ .on('click', function(e, d) { e.stopPropagation(); selectTopoNode(d); });
3822
+ nodeEnter.append('path').attr('class', 'node-hex');
3823
+ nodeEnter.append('title');
3824
+ nodeEnter.append('text').attr('class', 'node-label').attr('text-anchor', 'middle');
3825
+
3826
+ nodeSel = nodeEnter.merge(nodeSel);
3827
+ nodeSel.select('.node-hex')
3828
+ .attr('d', function(d) { return tHexPath(tHexSize(d)); })
3829
+ .attr('fill', function(d) { return TYPE_COLORS[d.type] || '#aaa'; })
3830
+ .attr('stroke', function(d) { var c = d3.color(TYPE_COLORS[d.type] || '#aaa'); return c ? c.brighter(0.8).formatHex() : '#ccc'; })
3831
+ .attr('fill-opacity', function(d) { return 0.6 + d.confidence * 0.4; })
3832
+ .classed('selected', function(d) { return d.id === topoSelectedId; });
3833
+ nodeSel.select('title').text(function(d) { return d.name + ' (' + d.type + ')\\nconf: ' + Math.round(d.confidence * 100) + '%'; });
3834
+ nodeLabelSel = nodeSel.select('.node-label')
3835
+ .attr('dy', function(d) { return tHexSize(d) + 13; })
3836
+ .text(function(d) { return d.name.length > 20 ? d.name.substring(0, 18) + '\\u2026' : d.name; });
3837
+
3838
+ sim = d3.forceSimulation(TOPO.nodes)
3839
+ .force('link', d3.forceLink(TOPO.links).id(function(d) { return d.id; }).distance(function(d) { return d.relationship === 'contains' ? 50 : 100; }).strength(0.4))
3840
+ .force('charge', d3.forceManyBody().strength(-280))
3841
+ .force('center', d3.forceCenter(gW() / 2, gH() / 2))
3842
+ .force('collision', d3.forceCollide().radius(function(d) { return tHexSize(d) + 10; }))
3843
+ .force('cluster', clusterForce)
3844
+ .on('tick', function() {
3845
+ updateHulls();
3846
+ linkSel.attr('x1', function(d) { return d.source.x; }).attr('y1', function(d) { return d.source.y; })
3847
+ .attr('x2', function(d) { return d.target.x; }).attr('y2', function(d) { return d.target.y; });
3848
+ linkLabelSel.attr('x', function(d) { return (d.source.x + d.target.x) / 2; })
3849
+ .attr('y', function(d) { return (d.source.y + d.target.y) / 2 - 4; });
3850
+ nodeSel.attr('transform', function(d) { return 'translate(' + d.x + ',' + d.y + ')'; });
3851
+ });
3852
+ }
3853
+ window.rebuildTopoGraph = rebuildTopoGraph;
3854
+
3855
+ function updateTopoLOD(k) {
3856
+ if (nodeLabelSel) nodeLabelSel.style('opacity', k > 0.5 ? Math.min(1, (k - 0.5) * 2) : 0);
3857
+ if (linkLabelSel) linkLabelSel.style('opacity', k > 1.2 ? Math.min(1, (k - 1.2) * 3) : 0);
3858
+ d3.selectAll('.hull-label').style('font-size', k < 0.4 ? '18px' : '13px');
3859
+ }
3860
+
3861
+ function updateTopoVisibility() {
3862
+ if (!nodeSel) return;
3863
+ nodeSel.style('display', function(d) { return layerVisible[d.layer] ? null : 'none'; });
3864
+ linkSel.style('display', function(d) {
3865
+ var s = TOPO.nodes.find(function(n) { return n.id === (d.source.id || d.source); });
3866
+ var t = TOPO.nodes.find(function(n) { return n.id === (d.target.id || d.target); });
3867
+ return (s && layerVisible[s.layer]) && (t && layerVisible[t.layer]) ? null : 'none';
3868
+ });
3869
+ linkLabelSel.style('display', function(d) {
3870
+ var s = TOPO.nodes.find(function(n) { return n.id === (d.source.id || d.source); });
3871
+ var t = TOPO.nodes.find(function(n) { return n.id === (d.target.id || d.target); });
3872
+ return (s && layerVisible[s.layer]) && (t && layerVisible[t.layer]) ? null : 'none';
3873
+ });
3874
+ }
3875
+
3876
+ rebuildTopoGraph();
3877
+ buildTopoList();
3878
+ updateTopoLOD(1);
3879
+
3880
+ svgEl.on('click', function() {
3881
+ topoSelectedId = null;
3882
+ d3.selectAll('.node-hex').classed('selected', false);
3883
+ buildTopoList(document.getElementById('topo-search').value);
3884
+ topoSidebar.innerHTML = '<h2>Infrastructure Map</h2><p class="hint">Click a node to view details.</p>';
3885
+ });
3886
+ }
3887
+
3888
+ // Init topology node list (non-D3 part)
3889
+ buildTopoList();
2992
3890
  </script>
2993
3891
  </body>
2994
3892
  </html>`;
2995
3893
  }
3894
+ function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml", "html", "map", "discovery", "sops"]) {
3895
+ mkdirSync2(outputDir, { recursive: true });
3896
+ mkdirSync2(join2(outputDir, "sops"), { recursive: true });
3897
+ mkdirSync2(join2(outputDir, "workflows"), { recursive: true });
3898
+ const nodes = db.getNodes(sessionId);
3899
+ const edges = db.getEdges(sessionId);
3900
+ if (formats.includes("mermaid")) {
3901
+ writeFileSync(join2(outputDir, "topology.mermaid"), generateTopologyMermaid(nodes, edges));
3902
+ writeFileSync(join2(outputDir, "dependencies.mermaid"), generateDependencyMermaid(nodes, edges));
3903
+ process.stderr.write("\u2713 topology.mermaid, dependencies.mermaid\n");
3904
+ }
3905
+ if (formats.includes("json")) {
3906
+ writeFileSync(join2(outputDir, "catalog.json"), exportJSON(db, sessionId));
3907
+ process.stderr.write("\u2713 catalog.json\n");
3908
+ }
3909
+ if (formats.includes("yaml")) {
3910
+ writeFileSync(join2(outputDir, "catalog-info.yaml"), exportBackstageYAML(nodes, edges));
3911
+ process.stderr.write("\u2713 catalog-info.yaml\n");
3912
+ }
3913
+ if (formats.includes("html")) {
3914
+ writeFileSync(join2(outputDir, "topology.html"), exportHTML(nodes, edges));
3915
+ process.stderr.write("\u2713 topology.html\n");
3916
+ }
3917
+ if (formats.includes("map")) {
3918
+ writeFileSync(join2(outputDir, "cartography-map.html"), exportCartographyMap(nodes, edges));
3919
+ process.stderr.write("\u2713 cartography-map.html\n");
3920
+ }
3921
+ if (formats.includes("discovery")) {
3922
+ writeFileSync(join2(outputDir, "discovery.html"), exportDiscoveryApp(nodes, edges));
3923
+ process.stderr.write("\u2713 discovery.html\n");
3924
+ }
3925
+ if (formats.includes("sops")) {
3926
+ const sops = db.getSOPs(sessionId);
3927
+ for (const sop of sops) {
3928
+ const filename = sop.title.toLowerCase().replace(/[^a-z0-9]+/g, "-") + ".md";
3929
+ writeFileSync(join2(outputDir, "sops", filename), exportSOPMarkdown(sop));
3930
+ const wfFilename = `workflow-${sop.workflowId.substring(0, 8)}.mermaid`;
3931
+ writeFileSync(join2(outputDir, "workflows", wfFilename), generateWorkflowMermaid(sop));
3932
+ }
3933
+ if (sops.length > 0) {
3934
+ process.stderr.write(`\u2713 ${sops.length} SOPs + workflow diagrams
3935
+ `);
3936
+ }
3937
+ }
3938
+ }
2996
3939
 
2997
3940
  // src/cli.ts
2998
- import { readFileSync as readFileSync2, existsSync as existsSync2, writeFileSync as writeFileSync2 } from "fs";
3941
+ import { readFileSync as readFileSync2, existsSync as existsSync2, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "fs";
2999
3942
  import { resolve } from "path";
3000
3943
  import { createInterface } from "readline";
3001
3944
  import { execSync as execSync2 } from "child_process";
@@ -3011,7 +3954,7 @@ function main() {
3011
3954
  const program = new Command();
3012
3955
  const CMD = "datasynx-cartography";
3013
3956
  const VERSION = "0.2.3";
3014
- program.name(CMD).description("AI-powered Infrastructure Cartography").version(VERSION);
3957
+ program.name(CMD).description("AI-powered Infrastructure Cartography & SOP Generation").version(VERSION);
3015
3958
  program.command("discover").description("Scan and map your infrastructure").option("--entry <hosts...>", "Entry points", ["localhost"]).option("--depth <n>", "Max crawl depth", "8").option("--max-turns <n>", "Max agent turns", "50").option("--model <m>", "Agent model", "claude-sonnet-4-5-20250929").option("--org <name>", "Organization name (for Backstage)").option("-o, --output <dir>", "Output directory", "./datasynx-output").option("--db <path>", "DB path").option("-v, --verbose", "Show agent reasoning", false).action(async (opts) => {
3016
3959
  checkPrerequisites();
3017
3960
  const config = defaultConfig({
@@ -3223,31 +4166,27 @@ function main() {
3223
4166
  exportAll(db, sessionId, config.outputDir);
3224
4167
  const osc8 = (url, label) => `\x1B]8;;${url}\x1B\\${label}\x1B]8;;\x1B\\`;
3225
4168
  const htmlPath = resolve(config.outputDir, "topology.html");
3226
- const hexmapPath = resolve(config.outputDir, "hexmap.html");
4169
+ const mapPath = resolve(config.outputDir, "cartography-map.html");
4170
+ const discoveryPath = resolve(config.outputDir, "discovery.html");
3227
4171
  const topoPath = resolve(config.outputDir, "topology.mermaid");
3228
4172
  w("\n");
3229
- if (existsSync2(hexmapPath)) {
3230
- const fileUrl = `file://${hexmapPath}`;
3231
- w(` ${green("\u2192")} ${osc8(fileUrl, bold("Open hexmap.html"))} ${dim("\u2190 Data Cartography Map")}
4173
+ if (existsSync2(discoveryPath)) {
4174
+ w(` ${green("\u2192")} ${osc8(`file://${discoveryPath}`, bold("Open discovery.html"))} ${dim("\u2190 Enterprise Discovery Frontend")}
3232
4175
  `);
3233
- w(` ${dim(fileUrl)}
4176
+ }
4177
+ if (existsSync2(mapPath)) {
4178
+ w(` ${green("\u2192")} ${osc8(`file://${mapPath}`, bold("Open cartography-map.html"))} ${dim("\u2190 Hex Map")}
3234
4179
  `);
3235
4180
  }
3236
4181
  if (existsSync2(htmlPath)) {
3237
- const fileUrl = `file://${htmlPath}`;
3238
- w(` ${green("\u2192")} ${osc8(fileUrl, bold("Open topology.html"))}
3239
- `);
3240
- w(` ${dim(fileUrl)}
4182
+ w(` ${green("\u2192")} ${osc8(`file://${htmlPath}`, bold("Open topology.html"))}
3241
4183
  `);
3242
4184
  }
3243
4185
  if (existsSync2(topoPath)) {
3244
4186
  try {
3245
4187
  const code = readFileSync2(topoPath, "utf8");
3246
- const b64 = Buffer.from(JSON.stringify({ code, mermaid: { theme: "dark" } })).toString("base64url");
3247
- const liveUrl = `https://mermaid.live/view#base64:${b64}`;
3248
- w(` ${cyan("\u2192")} ${osc8(liveUrl, bold("Open in mermaid.live"))}
3249
- `);
3250
- w(` ${dim(liveUrl)}
4188
+ const b64 = Buffer.from(JSON.stringify({ code, mermaid: { theme: "dark" } })).toString("base64");
4189
+ w(` ${cyan("\u2192")} ${osc8(`https://mermaid.live/view#base64:${b64}`, bold("Open in mermaid.live"))}
3251
4190
  `);
3252
4191
  } catch {
3253
4192
  }
@@ -3302,7 +4241,7 @@ function main() {
3302
4241
  }
3303
4242
  db.close();
3304
4243
  });
3305
- program.command("export [session-id]").description("Generate all output files").option("-o, --output <dir>", "Output directory", "./datasynx-output").option("--format <fmt...>", "Formats: mermaid,json,yaml,html,hexmap,sops").action((sessionId, opts) => {
4244
+ program.command("export [session-id]").description("Generate all output files").option("-o, --output <dir>", "Output directory", "./datasynx-output").option("--format <fmt...>", "Formats: mermaid,json,yaml,html,map,sops").action((sessionId, opts) => {
3306
4245
  const config = defaultConfig({ outputDir: opts.output });
3307
4246
  const db = new CartographyDB(config.dbPath);
3308
4247
  const session = sessionId ? db.getSession(sessionId) : db.getLatestSession();
@@ -3312,34 +4251,33 @@ function main() {
3312
4251
  process.exitCode = 1;
3313
4252
  return;
3314
4253
  }
3315
- const formats = opts.format ?? ["mermaid", "json", "yaml", "html", "hexmap", "sops"];
4254
+ const formats = opts.format ?? ["mermaid", "json", "yaml", "html", "map", "sops"];
3316
4255
  exportAll(db, session.id, opts.output, formats);
3317
4256
  process.stderr.write(`\u2713 Exported to: ${opts.output}
3318
4257
  `);
3319
4258
  db.close();
3320
4259
  });
3321
- program.command("map [session-id]").description("Open the interactive Data Cartography hex map in your browser").option("-o, --output <dir>", "Output directory", "./datasynx-output").action((sessionId, opts) => {
4260
+ program.command("map [session-id]").description("Open the interactive Data Cartography hex map in your browser").option("-o, --output <dir>", "Output directory", "./datasynx-output").option("--theme <theme>", "Theme: light or dark", "light").action((sessionId, opts) => {
3322
4261
  const config = defaultConfig({ outputDir: opts.output });
3323
4262
  const db = new CartographyDB(config.dbPath);
3324
4263
  const session = sessionId ? db.getSession(sessionId) : db.getLatestSession();
3325
4264
  if (!session) {
3326
- process.stderr.write("\u274C No session found. Run discover first.\n");
4265
+ process.stderr.write("No session found. Run discover first.\n");
3327
4266
  db.close();
3328
4267
  process.exitCode = 1;
3329
4268
  return;
3330
4269
  }
3331
4270
  const nodes = db.getNodes(session.id);
3332
- const connections = db.getConnections(session.id);
4271
+ const edges = db.getEdges(session.id);
3333
4272
  const outDir = resolve(opts.output);
3334
- const outPath = resolve(outDir, "hexmap.html");
3335
- import("fs").then(({ mkdirSync: mkdirSync3 }) => mkdirSync3(outDir, { recursive: true })).catch(() => {
3336
- });
3337
- writeFileSync2(outPath, exportHexMap(nodes, connections));
4273
+ mkdirSync3(outDir, { recursive: true });
4274
+ const outPath = resolve(outDir, "cartography-map.html");
4275
+ writeFileSync2(outPath, exportCartographyMap(nodes, edges, { theme: opts.theme }));
3338
4276
  db.close();
3339
4277
  const osc8 = (url, label) => `\x1B]8;;${url}\x1B\\${label}\x1B]8;;\x1B\\`;
3340
4278
  const fileUrl = `file://${outPath}`;
3341
4279
  process.stderr.write(`
3342
- ${green("\u2192")} ${osc8(fileUrl, bold("Open hexmap.html"))}
4280
+ ${green("OK")} ${osc8(fileUrl, bold("Open cartography-map.html"))}
3343
4281
  `);
3344
4282
  process.stderr.write(` ${dim(fileUrl)}
3345
4283
 
@@ -3592,7 +4530,9 @@ ${infraSummary.substring(0, 12e3)}`;
3592
4530
  out(dim(" catalog-info.yaml Backstage service catalog\n"));
3593
4531
  out(dim(" topology.mermaid Infrastructure topology (graph TB)\n"));
3594
4532
  out(dim(" dependencies.mermaid Service dependencies (graph LR)\n"));
4533
+ out(dim(" discovery.html Enterprise discovery frontend (Map + Topology)\n"));
3595
4534
  out(dim(" topology.html Interactive D3.js force graph\n"));
4535
+ out(dim(" cartography-map.html Hex grid data cartography map\n"));
3596
4536
  out(dim(" sops/ Generated SOPs as Markdown\n"));
3597
4537
  out(dim(" workflows/ Workflow flowcharts as Mermaid\n"));
3598
4538
  out("\n");
@@ -3708,9 +4648,6 @@ ${infraSummary.substring(0, 12e3)}`;
3708
4648
  const port = entry["port"];
3709
4649
  const tags = entry["tags"] ?? [];
3710
4650
  const metadata = entry["metadata"] ?? {};
3711
- const domain = entry["domain"];
3712
- const subDomain = entry["subDomain"];
3713
- const qualityScore = entry["qualityScore"];
3714
4651
  if (!type || !name) {
3715
4652
  w(yellow(` \u26A0 Skipped (no type/name): ${JSON.stringify(entry)}
3716
4653
  `));
@@ -3724,10 +4661,7 @@ ${infraSummary.substring(0, 12e3)}`;
3724
4661
  discoveredVia: "manual",
3725
4662
  confidence: 1,
3726
4663
  metadata: { ...metadata, ...host ? { host } : {}, ...port ? { port } : {} },
3727
- tags,
3728
- domain,
3729
- subDomain,
3730
- qualityScore
4664
+ tags
3731
4665
  });
3732
4666
  out(` ${green("+")} ${cyan(id)} ${dim("(" + type + ")")}
3733
4667
  `);
@@ -3740,7 +4674,7 @@ ${infraSummary.substring(0, 12e3)}`;
3740
4674
  `);
3741
4675
  return;
3742
4676
  }
3743
- const { NODE_TYPES: NODE_TYPES2 } = await import("./types-A6K73WE4.js");
4677
+ const { NODE_TYPES: NODE_TYPES2 } = await import("./types-ZD6G5JKR.js");
3744
4678
  if (!process.stdin.isTTY) {
3745
4679
  w(red("\n \u2717 Interactive mode requires a terminal (use --file for non-interactive)\n\n"));
3746
4680
  process.exitCode = 1;
@@ -3783,15 +4717,9 @@ ${infraSummary.substring(0, 12e3)}`;
3783
4717
  const hostRaw = (await ask(` ${cyan("Host / IP")} ${dim("[optional, Enter=skip]")}: `)).trim();
3784
4718
  const portRaw = (await ask(` ${cyan("Port")} ${dim("[optional]")}: `)).trim();
3785
4719
  const tagsRaw = (await ask(` ${cyan("Tags")} ${dim("[comma-separated, optional]")}: `)).trim();
3786
- const domainRaw = (await ask(` ${cyan("Domain")} ${dim('[e.g. "Marketing", "Finance", optional]')}: `)).trim();
3787
- const subDomainRaw = (await ask(` ${cyan("Sub-domain")} ${dim('[e.g. "Forecast client orders", optional]')}: `)).trim();
3788
- const qualityRaw = (await ask(` ${cyan("Quality score")} ${dim("[0\u2013100, optional]")}: `)).trim();
3789
4720
  const host = hostRaw || void 0;
3790
4721
  const port = portRaw ? parseInt(portRaw, 10) : void 0;
3791
4722
  const tags = tagsRaw ? tagsRaw.split(",").map((t) => t.trim()).filter(Boolean) : [];
3792
- const domain = domainRaw || void 0;
3793
- const subDomain = subDomainRaw || void 0;
3794
- const qualityScore = qualityRaw ? Math.max(0, Math.min(100, parseFloat(qualityRaw))) : void 0;
3795
4723
  const id = host ? `${nodeType}:${host}${port ? ":" + port : ""}` : `${nodeType}:${name.toLowerCase().replace(/\s+/g, "-")}`;
3796
4724
  db.upsertNode(sessionId, {
3797
4725
  id,
@@ -3800,10 +4728,7 @@ ${infraSummary.substring(0, 12e3)}`;
3800
4728
  discoveredVia: "manual",
3801
4729
  confidence: 1,
3802
4730
  metadata: { ...host ? { host } : {}, ...port ? { port } : {} },
3803
- tags,
3804
- domain,
3805
- subDomain,
3806
- qualityScore
4731
+ tags
3807
4732
  });
3808
4733
  out(` ${green("+")} ${cyan(id)}
3809
4734
  `);