@datasynx/agentic-ai-cartography 0.3.3 → 0.4.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
@@ -4,7 +4,7 @@ import {
4
4
  NODE_TYPES,
5
5
  SOPStepSchema,
6
6
  defaultConfig
7
- } from "./chunk-FFNOC6HF.js";
7
+ } from "./chunk-7NRS3WP6.js";
8
8
  import {
9
9
  scanAllBookmarks,
10
10
  scanAllHistory
@@ -79,9 +79,21 @@ CREATE TABLE IF NOT EXISTS nodes (
79
79
  confidence REAL DEFAULT 0.5,
80
80
  metadata TEXT NOT NULL DEFAULT '{}',
81
81
  tags TEXT NOT NULL DEFAULT '[]',
82
+ domain TEXT,
83
+ sub_domain TEXT,
84
+ quality_score REAL,
82
85
  PRIMARY KEY (id, session_id)
83
86
  );
84
87
 
88
+ CREATE TABLE IF NOT EXISTS connections (
89
+ id TEXT PRIMARY KEY,
90
+ session_id TEXT NOT NULL REFERENCES sessions(id),
91
+ source_asset_id TEXT NOT NULL,
92
+ target_asset_id TEXT NOT NULL,
93
+ type TEXT,
94
+ created_at TEXT NOT NULL
95
+ );
96
+
85
97
  CREATE TABLE IF NOT EXISTS edges (
86
98
  id TEXT PRIMARY KEY,
87
99
  session_id TEXT NOT NULL REFERENCES sessions(id),
@@ -156,6 +168,7 @@ CREATE INDEX IF NOT EXISTS idx_edges_session ON edges(session_id);
156
168
  CREATE INDEX IF NOT EXISTS idx_events_session ON activity_events(session_id);
157
169
  CREATE INDEX IF NOT EXISTS idx_events_task ON activity_events(task_id);
158
170
  CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_id);
171
+ CREATE INDEX IF NOT EXISTS idx_connections_session ON connections(session_id);
159
172
  `;
160
173
  var CartographyDB = class {
161
174
  db;
@@ -171,7 +184,24 @@ var CartographyDB = class {
171
184
  const version = this.db.pragma("user_version", { simple: true });
172
185
  if (version === 0) {
173
186
  this.db.exec(SCHEMA);
174
- this.db.pragma("user_version = 1");
187
+ this.db.pragma("user_version = 2");
188
+ } else if (version === 1) {
189
+ const cols = this.db.prepare("PRAGMA table_info(nodes)").all().map((c) => c.name);
190
+ if (!cols.includes("domain")) this.db.exec("ALTER TABLE nodes ADD COLUMN domain TEXT");
191
+ if (!cols.includes("sub_domain")) this.db.exec("ALTER TABLE nodes ADD COLUMN sub_domain TEXT");
192
+ if (!cols.includes("quality_score")) this.db.exec("ALTER TABLE nodes ADD COLUMN quality_score REAL");
193
+ this.db.exec(`
194
+ CREATE TABLE IF NOT EXISTS connections (
195
+ id TEXT PRIMARY KEY,
196
+ session_id TEXT NOT NULL REFERENCES sessions(id),
197
+ source_asset_id TEXT NOT NULL,
198
+ target_asset_id TEXT NOT NULL,
199
+ type TEXT,
200
+ created_at TEXT NOT NULL
201
+ );
202
+ CREATE INDEX IF NOT EXISTS idx_connections_session ON connections(session_id);
203
+ `);
204
+ this.db.pragma("user_version = 2");
175
205
  }
176
206
  }
177
207
  close() {
@@ -214,8 +244,9 @@ var CartographyDB = class {
214
244
  upsertNode(sessionId, node, depth = 0) {
215
245
  this.db.prepare(`
216
246
  INSERT OR REPLACE INTO nodes
217
- (id, session_id, type, name, discovered_via, discovered_at, depth, confidence, metadata, tags)
218
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
247
+ (id, session_id, type, name, discovered_via, discovered_at, depth, confidence, metadata, tags,
248
+ domain, sub_domain, quality_score)
249
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
219
250
  `).run(
220
251
  node.id,
221
252
  sessionId,
@@ -226,12 +257,18 @@ var CartographyDB = class {
226
257
  depth,
227
258
  node.confidence,
228
259
  JSON.stringify(node.metadata ?? {}),
229
- JSON.stringify(node.tags ?? [])
260
+ JSON.stringify(node.tags ?? []),
261
+ node.domain ?? null,
262
+ node.subDomain ?? null,
263
+ node.qualityScore ?? null
230
264
  );
231
265
  }
232
266
  getNodes(sessionId) {
233
267
  const rows = this.db.prepare("SELECT * FROM nodes WHERE session_id = ?").all(sessionId);
234
- return rows.map((r) => ({
268
+ return rows.map((r) => this.mapNode(r));
269
+ }
270
+ mapNode(r) {
271
+ return {
235
272
  id: r["id"],
236
273
  sessionId: r["session_id"],
237
274
  type: r["type"],
@@ -242,8 +279,11 @@ var CartographyDB = class {
242
279
  confidence: r["confidence"],
243
280
  metadata: JSON.parse(r["metadata"]),
244
281
  tags: JSON.parse(r["tags"]),
245
- pathId: r["path_id"]
246
- }));
282
+ pathId: r["path_id"],
283
+ domain: r["domain"] ?? void 0,
284
+ subDomain: r["sub_domain"] ?? void 0,
285
+ qualityScore: r["quality_score"] ?? void 0
286
+ };
247
287
  }
248
288
  deleteNode(sessionId, nodeId) {
249
289
  this.db.prepare("DELETE FROM nodes WHERE session_id = ? AND id = ?").run(sessionId, nodeId);
@@ -455,6 +495,33 @@ var CartographyDB = class {
455
495
  generatedAt: r["generated_at"]
456
496
  }));
457
497
  }
498
+ // ── Connections (user-created hex map links) ─────────────────────────────
499
+ upsertConnection(sessionId, conn) {
500
+ const existing = this.db.prepare(
501
+ "SELECT id FROM connections WHERE session_id = ? AND source_asset_id = ? AND target_asset_id = ?"
502
+ ).get(sessionId, conn.sourceAssetId, conn.targetAssetId);
503
+ if (existing) return existing.id;
504
+ const id = crypto.randomUUID();
505
+ this.db.prepare(`
506
+ INSERT INTO connections (id, session_id, source_asset_id, target_asset_id, type, created_at)
507
+ VALUES (?, ?, ?, ?, ?, ?)
508
+ `).run(id, sessionId, conn.sourceAssetId, conn.targetAssetId, conn.type ?? null, (/* @__PURE__ */ new Date()).toISOString());
509
+ return id;
510
+ }
511
+ getConnections(sessionId) {
512
+ const rows = this.db.prepare("SELECT * FROM connections WHERE session_id = ?").all(sessionId);
513
+ return rows.map((r) => ({
514
+ id: r["id"],
515
+ sessionId: r["session_id"],
516
+ sourceAssetId: r["source_asset_id"],
517
+ targetAssetId: r["target_asset_id"],
518
+ type: r["type"] ?? void 0,
519
+ createdAt: r["created_at"]
520
+ }));
521
+ }
522
+ deleteConnection(sessionId, connectionId) {
523
+ this.db.prepare("DELETE FROM connections WHERE session_id = ? AND id = ?").run(sessionId, connectionId);
524
+ }
458
525
  // ── Approvals ───────────────────────────
459
526
  setApproval(pattern, action) {
460
527
  this.db.prepare(`
@@ -496,7 +563,10 @@ async function createCartographyTools(db, sessionId, opts = {}) {
496
563
  discoveredVia: z.string(),
497
564
  confidence: z.number().min(0).max(1),
498
565
  metadata: z.record(z.unknown()).optional(),
499
- tags: z.array(z.string()).optional()
566
+ tags: z.array(z.string()).optional(),
567
+ domain: z.string().optional().describe('Business domain, e.g. "Marketing", "Finance"'),
568
+ subDomain: z.string().optional().describe('Sub-domain, e.g. "Forecast client orders"'),
569
+ qualityScore: z.number().min(0).max(100).optional().describe("Data quality score 0\u2013100")
500
570
  }, async (args) => {
501
571
  const node = {
502
572
  id: stripSensitive(args["id"]),
@@ -505,7 +575,10 @@ async function createCartographyTools(db, sessionId, opts = {}) {
505
575
  discoveredVia: args["discoveredVia"],
506
576
  confidence: args["confidence"],
507
577
  metadata: args["metadata"] ?? {},
508
- tags: args["tags"] ?? []
578
+ tags: args["tags"] ?? [],
579
+ domain: args["domain"],
580
+ subDomain: args["subDomain"],
581
+ qualityScore: args["qualityScore"]
509
582
  };
510
583
  db.upsertNode(sessionId, node);
511
584
  return { content: [{ type: "text", text: `\u2713 Node: ${node.id}` }] };
@@ -600,14 +673,14 @@ async function createCartographyTools(db, sessionId, opts = {}) {
600
673
  tool("scan_local_databases", "Scan for local database files and running DB servers \u2014 PostgreSQL databases, MySQL, SQLite files from installed apps", {
601
674
  deep: z.boolean().default(false).optional().describe("Also search home directory recursively for SQLite/DB files (slower)")
602
675
  }, async (args) => {
603
- const { execSync: execSync2 } = await import("child_process");
676
+ const { execSync: execSync3 } = await import("child_process");
604
677
  const { homedir } = await import("os");
605
678
  const { existsSync: existsSync3 } = await import("fs");
606
679
  const deep = args["deep"] ?? false;
607
680
  const HOME = homedir();
608
681
  const run = (cmd) => {
609
682
  try {
610
- return execSync2(cmd, { stdio: "pipe", timeout: 1e4, shell: "/bin/sh" }).toString().trim();
683
+ return execSync3(cmd, { stdio: "pipe", timeout: 1e4, shell: "/bin/sh" }).toString().trim();
611
684
  } catch {
612
685
  return "";
613
686
  }
@@ -634,12 +707,12 @@ ${v}`).join("\n\n");
634
707
  tool("scan_k8s_resources", "Scan Kubernetes cluster via kubectl \u2014 100% readonly (get, describe)", {
635
708
  namespace: z.string().optional().describe("Filter by namespace \u2014 empty = all namespaces")
636
709
  }, async (args) => {
637
- const { execSync: execSync2 } = await import("child_process");
710
+ const { execSync: execSync3 } = await import("child_process");
638
711
  const ns = args["namespace"];
639
712
  const nsFlag = ns ? `-n ${ns}` : "--all-namespaces";
640
713
  const run = (cmd) => {
641
714
  try {
642
- return execSync2(cmd, { stdio: "pipe", timeout: 15e3, shell: "/bin/sh" }).toString().trim();
715
+ return execSync3(cmd, { stdio: "pipe", timeout: 15e3, shell: "/bin/sh" }).toString().trim();
643
716
  } catch (e) {
644
717
  return `(error: ${e instanceof Error ? e.message.split("\n")[0] : String(e)})`;
645
718
  }
@@ -663,7 +736,7 @@ ${run(c)}`).join("\n\n");
663
736
  region: z.string().optional().describe("AWS Region \u2014 default: AWS_DEFAULT_REGION or profile"),
664
737
  profile: z.string().optional().describe("AWS CLI profile")
665
738
  }, async (args) => {
666
- const { execSync: execSync2 } = await import("child_process");
739
+ const { execSync: execSync3 } = await import("child_process");
667
740
  const region = args["region"];
668
741
  const profile = args["profile"];
669
742
  const env = { ...process.env };
@@ -671,7 +744,7 @@ ${run(c)}`).join("\n\n");
671
744
  const pf = profile ? `--profile ${profile}` : "";
672
745
  const run = (cmd) => {
673
746
  try {
674
- return execSync2(cmd, { stdio: "pipe", timeout: 2e4, shell: "/bin/sh", env }).toString().trim();
747
+ return execSync3(cmd, { stdio: "pipe", timeout: 2e4, shell: "/bin/sh", env }).toString().trim();
675
748
  } catch (e) {
676
749
  return `(error: ${e instanceof Error ? e.message.split("\n")[0] : String(e)})`;
677
750
  }
@@ -693,12 +766,12 @@ ${run(c)}`).join("\n\n");
693
766
  tool("scan_gcp_resources", "Scan Google Cloud Platform via gcloud CLI \u2014 100% readonly (list, describe)", {
694
767
  project: z.string().optional().describe("GCP Project ID \u2014 default: current gcloud project")
695
768
  }, async (args) => {
696
- const { execSync: execSync2 } = await import("child_process");
769
+ const { execSync: execSync3 } = await import("child_process");
697
770
  const project = args["project"];
698
771
  const pf = project ? `--project ${project}` : "";
699
772
  const run = (cmd) => {
700
773
  try {
701
- return execSync2(cmd, { stdio: "pipe", timeout: 2e4, shell: "/bin/sh" }).toString().trim();
774
+ return execSync3(cmd, { stdio: "pipe", timeout: 2e4, shell: "/bin/sh" }).toString().trim();
702
775
  } catch (e) {
703
776
  return `(error: ${e instanceof Error ? e.message.split("\n")[0] : String(e)})`;
704
777
  }
@@ -722,14 +795,14 @@ ${run(c)}`).join("\n\n");
722
795
  subscription: z.string().optional().describe("Azure Subscription ID"),
723
796
  resourceGroup: z.string().optional().describe("Filter by resource group")
724
797
  }, async (args) => {
725
- const { execSync: execSync2 } = await import("child_process");
798
+ const { execSync: execSync3 } = await import("child_process");
726
799
  const sub = args["subscription"];
727
800
  const rg = args["resourceGroup"];
728
801
  const sf = sub ? `--subscription ${sub}` : "";
729
802
  const rf = rg ? `--resource-group ${rg}` : "";
730
803
  const run = (cmd) => {
731
804
  try {
732
- return execSync2(cmd, { stdio: "pipe", timeout: 2e4, shell: "/bin/sh" }).toString().trim();
805
+ return execSync3(cmd, { stdio: "pipe", timeout: 2e4, shell: "/bin/sh" }).toString().trim();
733
806
  } catch (e) {
734
807
  return `(error: ${e instanceof Error ? e.message.split("\n")[0] : String(e)})`;
735
808
  }
@@ -752,11 +825,11 @@ ${run(c)}`).join("\n\n");
752
825
  tool("scan_installed_apps", "Scan all installed apps and tools \u2014 IDEs, office, dev tools, business apps, databases", {
753
826
  searchHint: z.string().optional().describe('Optional search term to find specific tools (e.g. "hubspot windsurf cursor")')
754
827
  }, async (args) => {
755
- const { execSync: execSync2 } = await import("child_process");
828
+ const { execSync: execSync3 } = await import("child_process");
756
829
  const hint = args["searchHint"];
757
830
  const run = (cmd) => {
758
831
  try {
759
- return execSync2(cmd, { stdio: "pipe", timeout: 15e3, shell: "/bin/sh" }).toString().trim();
832
+ return execSync3(cmd, { stdio: "pipe", timeout: 15e3, shell: "/bin/sh" }).toString().trim();
760
833
  } catch {
761
834
  return "";
762
835
  }
@@ -1137,6 +1210,227 @@ Use ask_user when you need context from the user.`;
1137
1210
  // src/exporter.ts
1138
1211
  import { mkdirSync as mkdirSync2, writeFileSync } from "fs";
1139
1212
  import { join as join2 } from "path";
1213
+
1214
+ // src/hex.ts
1215
+ 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
+ return { x, y };
1219
+ }
1220
+ var HEX_DIRECTIONS = [
1221
+ { q: 1, r: 0 },
1222
+ { q: 1, r: -1 },
1223
+ { q: 0, r: -1 },
1224
+ { q: -1, r: 0 },
1225
+ { q: -1, r: 1 },
1226
+ { q: 0, r: 1 }
1227
+ ];
1228
+ function hexDistance(a, b) {
1229
+ 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
+ }
1231
+ function hexRing(center, radius) {
1232
+ if (radius === 0) return [{ ...center }];
1233
+ const results = [];
1234
+ let hex = {
1235
+ q: center.q + HEX_DIRECTIONS[4].q * radius,
1236
+ r: center.r + HEX_DIRECTIONS[4].r * radius
1237
+ };
1238
+ for (let side = 0; side < 6; side++) {
1239
+ for (let step = 0; step < radius; step++) {
1240
+ results.push({ q: hex.q, r: hex.r });
1241
+ hex = {
1242
+ q: hex.q + HEX_DIRECTIONS[side].q,
1243
+ r: hex.r + HEX_DIRECTIONS[side].r
1244
+ };
1245
+ }
1246
+ }
1247
+ return results;
1248
+ }
1249
+ function hexSpiral(center, count) {
1250
+ const positions = [];
1251
+ let ring = 0;
1252
+ while (positions.length < count) {
1253
+ const ring_positions = hexRing(center, ring);
1254
+ for (const pos of ring_positions) {
1255
+ if (positions.length >= count) break;
1256
+ positions.push(pos);
1257
+ }
1258
+ ring++;
1259
+ }
1260
+ return positions;
1261
+ }
1262
+
1263
+ // 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) {
1299
+ const idx = allDomains.indexOf(domain);
1300
+ return DOMAIN_PALETTE[idx % DOMAIN_PALETTE.length];
1301
+ }
1302
+ var HEX_SIZE = 24;
1303
+ var CLUSTER_GAP = 3;
1304
+ function layoutClusters(domainSizes) {
1305
+ const placements = [];
1306
+ const occupied = /* @__PURE__ */ new Set();
1307
+ 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));
1316
+ }
1317
+ placements.push({ domain, positions, origin });
1318
+ }
1319
+ return placements;
1320
+ }
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));
1325
+ for (const candidate of candidates) {
1326
+ const testPositions = hexSpiral(candidate, newCount);
1327
+ let fits = true;
1328
+ for (const tp of testPositions) {
1329
+ for (const ep of existing.flatMap((p) => p.positions)) {
1330
+ if (hexDistance(tp, ep) < gap) {
1331
+ fits = false;
1332
+ break;
1333
+ }
1334
+ }
1335
+ if (!fits) break;
1336
+ }
1337
+ if (fits) return candidate;
1338
+ }
1339
+ }
1340
+ return { q: existing.length * 20, r: 0 };
1341
+ }
1342
+ function estimateRadius(count) {
1343
+ return Math.ceil(Math.sqrt(count));
1344
+ }
1345
+ function computeCentroid(positions, size) {
1346
+ let sx = 0, sy = 0;
1347
+ for (const { q, r } of positions) {
1348
+ const { x, y } = hexToPixel(q, r, size);
1349
+ sx += x;
1350
+ sy += y;
1351
+ }
1352
+ return { x: sx / positions.length, y: sy / positions.length };
1353
+ }
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);
1413
+ }
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;
1422
+ }
1423
+ }
1424
+ if (!isFinite(minX)) {
1425
+ minX = 0;
1426
+ minY = 0;
1427
+ maxX = 0;
1428
+ maxY = 0;
1429
+ }
1430
+ return { clusters, subClusters: subClustersMap, hexSize: size, bounds: { minX, minY, maxX, maxY } };
1431
+ }
1432
+
1433
+ // src/exporter.ts
1140
1434
  function nodeLayer(type) {
1141
1435
  if (type === "saas_tool") return "saas";
1142
1436
  if (["web_service", "api_endpoint"].includes(type)) return "web";
@@ -1880,7 +2174,7 @@ function exportSOPMarkdown(sop) {
1880
2174
  }
1881
2175
  return lines.join("\n");
1882
2176
  }
1883
- function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml", "html", "sops"]) {
2177
+ function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml", "html", "hexmap", "sops"]) {
1884
2178
  mkdirSync2(outputDir, { recursive: true });
1885
2179
  mkdirSync2(join2(outputDir, "sops"), { recursive: true });
1886
2180
  mkdirSync2(join2(outputDir, "workflows"), { recursive: true });
@@ -1916,12 +2210,795 @@ function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml
1916
2210
  `);
1917
2211
  }
1918
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) => ({
2229
+ id: a.id,
2230
+ name: a.name,
2231
+ domain: a.domain,
2232
+ subDomain: a.subDomain ?? null,
2233
+ qualityScore: a.qualityScore ?? null,
2234
+ metadata: a.metadata,
2235
+ q: a.position.q,
2236
+ r: a.position.r
2237
+ }))
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;
2255
+ return `<!DOCTYPE html>
2256
+ <html lang="en">
2257
+ <head>
2258
+ <meta charset="UTF-8"/>
2259
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
2260
+ <title>Data Cartography Map</title>
2261
+ <style>
2262
+ *{box-sizing:border-box;margin:0;padding:0}
2263
+ 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}
2265
+ #topbar{
2266
+ 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;
2274
+ }
2275
+ #topbar .nav-item:hover{background:#f1f5f9;color:#0f172a}
2276
+ #topbar .nav-item.active{background:#eff6ff;color:#2563eb;font-weight:500}
2277
+ #search-box{
2278
+ display:flex;align-items:center;gap:8px;background:#f1f5f9;
2279
+ border-radius:8px;padding:5px 10px;margin-left:8px;
2280
+ }
2281
+ #search-box input{
2282
+ border:none;background:transparent;font-size:13px;outline:none;width:160px;color:#0f172a;
2283
+ }
2284
+ #search-box input::placeholder{color:#94a3b8}
2285
+ #search-icon{color:#94a3b8;font-size:14px}
2286
+ #main{flex:1;display:flex;overflow:hidden;position:relative}
2287
+ #canvas-wrap{flex:1;position:relative;overflow:hidden;cursor:grab}
2288
+ #canvas-wrap.dragging{cursor:grabbing}
2289
+ #canvas-wrap.connecting{cursor:crosshair}
2290
+ canvas{display:block;width:100%;height:100%}
2291
+ /* Detail panel */
2292
+ #detail-panel{
2293
+ width:280px;background:#fff;border-left:1px solid #e2e8f0;
2294
+ display:flex;flex-direction:column;transform:translateX(100%);
2295
+ transition:transform .2s ease;z-index:5;flex-shrink:0;overflow-y:auto;
2296
+ }
2297
+ #detail-panel.open{transform:translateX(0)}
2298
+ #detail-panel .panel-header{
2299
+ padding:16px;border-bottom:1px solid #e2e8f0;display:flex;align-items:center;gap:10px;
2300
+ }
2301
+ #detail-panel .panel-header h3{font-size:14px;font-weight:600;flex:1;word-break:break-word}
2302
+ #detail-panel .close-btn{
2303
+ width:24px;height:24px;border:none;background:transparent;cursor:pointer;
2304
+ color:#94a3b8;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:16px;
2305
+ }
2306
+ #detail-panel .close-btn:hover{background:#f1f5f9;color:#0f172a}
2307
+ #detail-panel .panel-body{padding:12px 16px;display:flex;flex-direction:column;gap:12px}
2308
+ #detail-panel .meta-row{display:flex;flex-direction:column;gap:3px}
2309
+ #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}
2312
+ #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
+ /* Bottom-left toolbar */
2318
+ #toolbar-left{
2319
+ position:absolute;bottom:20px;left:20px;display:flex;gap:8px;z-index:10;
2320
+ }
2321
+ .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;
2324
+ 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;
2333
+ }
2334
+ /* Bottom-right toolbar */
2335
+ #toolbar-right{
2336
+ position:absolute;bottom:20px;right:20px;display:flex;flex-direction:column;
2337
+ align-items:flex-end;gap:8px;z-index:10;
2338
+ }
2339
+ #zoom-controls{display:flex;align-items:center;gap:6px}
2340
+ .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;
2348
+ }
2349
+ #detail-selector{display:flex;flex-direction:column;gap:4px}
2350
+ .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;
2353
+ font-size:12px;font-weight:600;color:#64748b;display:flex;align-items:center;justify-content:center;
2354
+ }
2355
+ .detail-btn:hover{background:#f1f5f9;color:#0f172a}
2356
+ .detail-btn.active{background:#eff6ff;border-color:#3b82f6;color:#2563eb}
2357
+ #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;
2361
+ }
2362
+ #connect-btn.active{background:#fef3c7;border-color:#f59e0b}
2363
+ /* Tooltip */
2364
+ #tooltip{
2365
+ position:fixed;background:#1e293b;color:#fff;border-radius:8px;
2366
+ 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);
2368
+ }
2369
+ #tooltip .tt-name{font-weight:600;margin-bottom:2px}
2370
+ #tooltip .tt-domain{color:#94a3b8;font-size:11px}
2371
+ /* Empty state */
2372
+ #empty-state{
2373
+ position:absolute;inset:0;display:flex;flex-direction:column;
2374
+ align-items:center;justify-content:center;gap:12px;color:#94a3b8;
2375
+ }
2376
+ #empty-state .es-icon{font-size:48px}
2377
+ #empty-state p{font-size:14px}
2378
+ /* Theme toggle */
2379
+ #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;
2383
+ }
2384
+ /* Dark mode overrides */
2385
+ body.dark{background:#0f172a;color:#e2e8f0}
2386
+ 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
+ body.dark #search-box{background:#334155}
2391
+ body.dark #search-box input{color:#f1f5f9}
2392
+ 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
+ body.dark .tb-btn,body.dark .zoom-btn,body.dark .detail-btn,body.dark #connect-btn,body.dark #theme-btn{
2396
+ background:#1e293b;border-color:#334155;color:#e2e8f0;
2397
+ }
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 */
2401
+ #connect-hint{
2402
+ position:absolute;top:12px;left:50%;transform:translateX(-50%);
2403
+ background:#fef3c7;border:1px solid #f59e0b;color:#92400e;
2404
+ padding:6px 14px;border-radius:20px;font-size:12px;font-weight:500;
2405
+ display:none;z-index:20;pointer-events:none;
2406
+ }
2407
+ </style>
2408
+ </head>
2409
+ <body>
2410
+ <!-- Top bar -->
2411
+ <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>
2418
+ <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"/>
2421
+ </div>
2422
+ <button id="theme-btn" title="Toggle dark/light mode" aria-label="Toggle theme">\u{1F319}</button>
2423
+ </div>
2424
+
2425
+ <!-- Main area -->
2426
+ <div id="main">
2427
+ <div id="canvas-wrap" role="application" aria-label="Data cartography hex map" tabindex="0">
2428
+ <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>' : ""}
2430
+ </div>
2431
+ <div id="detail-panel" role="complementary" aria-label="Asset details">
2432
+ <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>
2435
+ </div>
2436
+ <div class="panel-body" id="dp-body"></div>
2437
+ </div>
2438
+ </div>
2439
+
2440
+ <!-- Bottom-left toolbar -->
2441
+ <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>
2445
+ </div>
2446
+
2447
+ <!-- Bottom-right toolbar -->
2448
+ <div id="toolbar-right">
2449
+ <div id="zoom-controls">
2450
+ <button class="zoom-btn" id="zoom-out" aria-label="Zoom out">\u2212</button>
2451
+ <span id="zoom-pct">100%</span>
2452
+ <button class="zoom-btn" id="zoom-in" aria-label="Zoom in">+</button>
2453
+ </div>
2454
+ <div id="detail-selector">
2455
+ <button class="detail-btn" id="dl-1" aria-label="Detail level 1">1</button>
2456
+ <button class="detail-btn active" id="dl-2" aria-label="Detail level 2">2</button>
2457
+ <button class="detail-btn" id="dl-3" aria-label="Detail level 3">3</button>
2458
+ <button class="detail-btn" id="dl-4" aria-label="Detail level 4">4</button>
2459
+ </div>
2460
+ <button id="connect-btn" title="Connection tool" aria-label="Toggle connection tool">\u{1F517}</button>
2461
+ </div>
2462
+
2463
+ <!-- Connection mode hint -->
2464
+ <div id="connect-hint">Click two assets to create a connection</div>
2465
+
2466
+ <!-- Hover tooltip -->
2467
+ <div id="tooltip" role="tooltip">
2468
+ <div class="tt-name" id="tt-name"></div>
2469
+ <div class="tt-domain" id="tt-domain"></div>
2470
+ </div>
2471
+
2472
+ <script>
2473
+ (function() {
2474
+ 'use strict';
2475
+
2476
+ // \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};
2481
+ const IS_EMPTY = ${isEmpty};
2482
+
2483
+ // Build flat asset index
2484
+ 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 });
2488
+ }
2489
+ }
2490
+
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
2492
+ const canvas = document.getElementById('hexmap');
2493
+ const ctx = canvas.getContext('2d');
2494
+ const wrap = document.getElementById('canvas-wrap');
2495
+
2496
+ let W = 0, H = 0;
2497
+ function resize() {
2498
+ const dpr = window.devicePixelRatio || 1;
2499
+ W = wrap.clientWidth; H = wrap.clientHeight;
2500
+ canvas.width = W * dpr; canvas.height = H * dpr;
2501
+ canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
2502
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
2503
+ draw();
2504
+ }
2505
+ window.addEventListener('resize', resize);
2506
+
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
2508
+ 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;
2518
+ let searchQuery = '';
2519
+ let localConnections = [...CONNECTIONS];
2520
+
2521
+ 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;
2529
+ }
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 };
2545
+ }
2546
+
2547
+ // \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
+ function hexPath(cx, cy, r) {
2549
+ ctx.beginPath();
2550
+ 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);
2554
+ }
2555
+ ctx.closePath();
2556
+ }
2557
+
2558
+ function draw() {
2559
+ ctx.clearRect(0, 0, W, H);
2560
+
2561
+ // Background
2562
+ ctx.fillStyle = isDark ? '#0f172a' : '#f8fafc';
2563
+ ctx.fillRect(0, 0, W, H);
2564
+
2565
+ if (IS_EMPTY) return;
2566
+
2567
+ const size = HEX_SIZE * scale;
2568
+ const matchedIds = getSearchMatches();
2569
+ const hasSearch = searchQuery.length > 0;
2570
+
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
2572
+ ctx.save();
2573
+ ctx.strokeStyle = isDark ? 'rgba(148,163,184,0.4)' : 'rgba(100,116,139,0.3)';
2574
+ ctx.lineWidth = 1.5;
2575
+ ctx.setLineDash([4,4]);
2576
+ for (const conn of localConnections) {
2577
+ const src = assetIndex.get(conn.sourceAssetId);
2578
+ 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();
2583
+ }
2584
+ ctx.setLineDash([]);
2585
+ ctx.restore();
2586
+
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) {
2589
+ const baseColor = cluster.color;
2590
+ const isClusterMatch = !hasSearch || cluster.assets.some(a =>
2591
+ matchedIds.has(a.id)
2592
+ );
2593
+ const clusterDim = hasSearch && !isClusterMatch;
2594
+
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;
2601
+
2602
+ // Frustum cull
2603
+ if (cx+size<0 || cx-size>W || cy+size<0 || cy-size>H) continue;
2604
+
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);
2608
+
2609
+ // Quality layer override
2610
+ if (showQuality && asset.qualityScore !== null) {
2611
+ const q = asset.qualityScore;
2612
+ if (q < 40) fillColor = '#ef4444';
2613
+ else if (q < 70) fillColor = '#f97316';
2614
+ }
2615
+
2616
+ // Dim non-matching in search
2617
+ const alpha = clusterDim ? 0.18 : 1;
2618
+
2619
+ // Hover / selected highlight
2620
+ const isHovered = asset.id === hoveredAssetId;
2621
+ const isSelected = asset.id === selectedAssetId;
2622
+ const isConnectFirst = asset.id === connectFirst;
2623
+
2624
+ ctx.save();
2625
+ ctx.globalAlpha = alpha;
2626
+
2627
+ hexPath(cx, cy, size*0.92);
2628
+
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
+ }
2635
+ }
2636
+
2637
+ ctx.fillStyle = fillColor;
2638
+ ctx.fill();
2639
+
2640
+ if (isSelected || isConnectFirst) {
2641
+ ctx.strokeStyle = isConnectFirst ? '#f59e0b' : '#fff';
2642
+ ctx.lineWidth = 2.5;
2643
+ ctx.stroke();
2644
+ } else if (isHovered) {
2645
+ ctx.strokeStyle = isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.2)';
2646
+ ctx.lineWidth = 1.5;
2647
+ ctx.stroke();
2648
+ } else {
2649
+ ctx.strokeStyle = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.4)';
2650
+ ctx.lineWidth = 1;
2651
+ ctx.stroke();
2652
+ }
2653
+
2654
+ ctx.restore();
2655
+
2656
+ // Quality dot indicator
2657
+ if (showQuality && asset.qualityScore !== null && size > 8) {
2658
+ const q = asset.qualityScore;
2659
+ if (q < 70) {
2660
+ 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';
2663
+ ctx.fill();
2664
+ }
2665
+ }
2666
+
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;
2674
+ ctx.save();
2675
+ ctx.font = \`\${Math.max(8, Math.min(11, size*0.38))}px -apple-system,sans-serif\`;
2676
+ ctx.fillStyle = isDark ? 'rgba(255,255,255,0.85)' : 'rgba(255,255,255,0.9)';
2677
+ ctx.textAlign = 'center';
2678
+ ctx.textBaseline = 'middle';
2679
+ ctx.fillText(label, cx, cy);
2680
+ ctx.restore();
2681
+ }
2682
+ }
2683
+ }
2684
+
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);
2695
+ }
2696
+ }
2697
+
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
+ }
2706
+ }
2707
+ }
2708
+ }
2709
+
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;
2721
+ 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
2726
+ 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';
2737
+ ctx.fillText(text, x, y);
2738
+ ctx.restore();
2739
+ }
2740
+
2741
+ // \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
+ }
2754
+ }
2755
+ return null;
2756
+ }
2757
+
2758
+ // \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
+ 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
+ }
2771
+ }
2772
+ return matches;
2773
+ }
2774
+
2775
+ // \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;
2777
+
2778
+ wrap.addEventListener('mousedown', e => {
2779
+ if (e.button !== 0) return;
2780
+ dragging = true; lastMX = e.clientX; lastMY = e.clientY;
2781
+ wrap.classList.add('dragging');
2782
+ });
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';
2805
+ }
2806
+ });
2807
+
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();
2822
+ }
2823
+ return;
2824
+ }
2825
+ if (asset) {
2826
+ selectedAssetId = asset.id;
2827
+ showDetailPanel(asset);
2828
+ } else {
2829
+ selectedAssetId = null;
2830
+ document.getElementById('detail-panel').classList.remove('open');
2831
+ }
2832
+ draw();
2833
+ });
2834
+
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 => {
2855
+ 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();
2868
+ }
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();
2887
+ }
2888
+ });
2889
+
2890
+ // \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
+ 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>\`;
2917
+ }
2918
+
2919
+ document.getElementById('detail-panel').classList.add('open');
2920
+ }
2921
+
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>\`;
2925
+ }
2926
+
2927
+ function escHtml(s) {
2928
+ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
2929
+ }
2930
+
2931
+ document.getElementById('dp-close').addEventListener('click', () => {
2932
+ document.getElementById('detail-panel').classList.remove('open');
2933
+ selectedAssetId = null; draw();
2934
+ });
2935
+
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();
2943
+ });
2944
+ });
2945
+
2946
+ document.getElementById('btn-org').addEventListener('click', () => {
2947
+ showOrg = !showOrg;
2948
+ document.getElementById('btn-org').classList.toggle('active', showOrg);
2949
+ draw();
2950
+ });
2951
+ document.getElementById('btn-labels').addEventListener('click', () => {
2952
+ showLabels = !showLabels;
2953
+ document.getElementById('btn-labels').classList.toggle('active', showLabels);
2954
+ draw();
2955
+ });
2956
+ document.getElementById('btn-quality').addEventListener('click', () => {
2957
+ showQuality = !showQuality;
2958
+ document.getElementById('btn-quality').classList.toggle('active', showQuality);
2959
+ draw();
2960
+ });
2961
+
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();
2969
+ }
2970
+ document.getElementById('connect-btn').addEventListener('click', toggleConnectMode);
2971
+
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();
2977
+ });
2978
+
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();
2983
+ });
2984
+
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();
2990
+
2991
+ })();
2992
+ </script>
2993
+ </body>
2994
+ </html>`;
1919
2995
  }
1920
2996
 
1921
2997
  // src/cli.ts
1922
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
2998
+ import { readFileSync as readFileSync2, existsSync as existsSync2, writeFileSync as writeFileSync2 } from "fs";
1923
2999
  import { resolve } from "path";
1924
3000
  import { createInterface } from "readline";
3001
+ import { execSync as execSync2 } from "child_process";
1925
3002
  var bold = (s) => `\x1B[1m${s}\x1B[0m`;
1926
3003
  var dim = (s) => `\x1B[2m${s}\x1B[0m`;
1927
3004
  var cyan = (s) => `\x1B[36m${s}\x1B[0m`;
@@ -2146,8 +3223,16 @@ function main() {
2146
3223
  exportAll(db, sessionId, config.outputDir);
2147
3224
  const osc8 = (url, label) => `\x1B]8;;${url}\x1B\\${label}\x1B]8;;\x1B\\`;
2148
3225
  const htmlPath = resolve(config.outputDir, "topology.html");
3226
+ const hexmapPath = resolve(config.outputDir, "hexmap.html");
2149
3227
  const topoPath = resolve(config.outputDir, "topology.mermaid");
2150
3228
  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")}
3232
+ `);
3233
+ w(` ${dim(fileUrl)}
3234
+ `);
3235
+ }
2151
3236
  if (existsSync2(htmlPath)) {
2152
3237
  const fileUrl = `file://${htmlPath}`;
2153
3238
  w(` ${green("\u2192")} ${osc8(fileUrl, bold("Open topology.html"))}
@@ -2217,7 +3302,7 @@ function main() {
2217
3302
  }
2218
3303
  db.close();
2219
3304
  });
2220
- 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,sops").action((sessionId, opts) => {
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) => {
2221
3306
  const config = defaultConfig({ outputDir: opts.output });
2222
3307
  const db = new CartographyDB(config.dbPath);
2223
3308
  const session = sessionId ? db.getSession(sessionId) : db.getLatestSession();
@@ -2227,12 +3312,44 @@ function main() {
2227
3312
  process.exitCode = 1;
2228
3313
  return;
2229
3314
  }
2230
- const formats = opts.format ?? ["mermaid", "json", "yaml", "html", "sops"];
3315
+ const formats = opts.format ?? ["mermaid", "json", "yaml", "html", "hexmap", "sops"];
2231
3316
  exportAll(db, session.id, opts.output, formats);
2232
3317
  process.stderr.write(`\u2713 Exported to: ${opts.output}
2233
3318
  `);
2234
3319
  db.close();
2235
3320
  });
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) => {
3322
+ const config = defaultConfig({ outputDir: opts.output });
3323
+ const db = new CartographyDB(config.dbPath);
3324
+ const session = sessionId ? db.getSession(sessionId) : db.getLatestSession();
3325
+ if (!session) {
3326
+ process.stderr.write("\u274C No session found. Run discover first.\n");
3327
+ db.close();
3328
+ process.exitCode = 1;
3329
+ return;
3330
+ }
3331
+ const nodes = db.getNodes(session.id);
3332
+ const connections = db.getConnections(session.id);
3333
+ 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));
3338
+ db.close();
3339
+ const osc8 = (url, label) => `\x1B]8;;${url}\x1B\\${label}\x1B]8;;\x1B\\`;
3340
+ const fileUrl = `file://${outPath}`;
3341
+ process.stderr.write(`
3342
+ ${green("\u2192")} ${osc8(fileUrl, bold("Open hexmap.html"))}
3343
+ `);
3344
+ process.stderr.write(` ${dim(fileUrl)}
3345
+
3346
+ `);
3347
+ try {
3348
+ const cmd = process.platform === "darwin" ? `open "${outPath}"` : process.platform === "win32" ? `start "" "${outPath}"` : `xdg-open "${outPath}"`;
3349
+ execSync2(cmd, { stdio: "ignore" });
3350
+ } catch {
3351
+ }
3352
+ });
2236
3353
  program.command("show [session-id]").description("Show session details").action((sessionId) => {
2237
3354
  const config = defaultConfig();
2238
3355
  const db = new CartographyDB(config.dbPath);
@@ -2591,6 +3708,9 @@ ${infraSummary.substring(0, 12e3)}`;
2591
3708
  const port = entry["port"];
2592
3709
  const tags = entry["tags"] ?? [];
2593
3710
  const metadata = entry["metadata"] ?? {};
3711
+ const domain = entry["domain"];
3712
+ const subDomain = entry["subDomain"];
3713
+ const qualityScore = entry["qualityScore"];
2594
3714
  if (!type || !name) {
2595
3715
  w(yellow(` \u26A0 Skipped (no type/name): ${JSON.stringify(entry)}
2596
3716
  `));
@@ -2604,7 +3724,10 @@ ${infraSummary.substring(0, 12e3)}`;
2604
3724
  discoveredVia: "manual",
2605
3725
  confidence: 1,
2606
3726
  metadata: { ...metadata, ...host ? { host } : {}, ...port ? { port } : {} },
2607
- tags
3727
+ tags,
3728
+ domain,
3729
+ subDomain,
3730
+ qualityScore
2608
3731
  });
2609
3732
  out(` ${green("+")} ${cyan(id)} ${dim("(" + type + ")")}
2610
3733
  `);
@@ -2617,7 +3740,7 @@ ${infraSummary.substring(0, 12e3)}`;
2617
3740
  `);
2618
3741
  return;
2619
3742
  }
2620
- const { NODE_TYPES: NODE_TYPES2 } = await import("./types-ROE3Z6HY.js");
3743
+ const { NODE_TYPES: NODE_TYPES2 } = await import("./types-A6K73WE4.js");
2621
3744
  if (!process.stdin.isTTY) {
2622
3745
  w(red("\n \u2717 Interactive mode requires a terminal (use --file for non-interactive)\n\n"));
2623
3746
  process.exitCode = 1;
@@ -2660,9 +3783,15 @@ ${infraSummary.substring(0, 12e3)}`;
2660
3783
  const hostRaw = (await ask(` ${cyan("Host / IP")} ${dim("[optional, Enter=skip]")}: `)).trim();
2661
3784
  const portRaw = (await ask(` ${cyan("Port")} ${dim("[optional]")}: `)).trim();
2662
3785
  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();
2663
3789
  const host = hostRaw || void 0;
2664
3790
  const port = portRaw ? parseInt(portRaw, 10) : void 0;
2665
3791
  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;
2666
3795
  const id = host ? `${nodeType}:${host}${port ? ":" + port : ""}` : `${nodeType}:${name.toLowerCase().replace(/\s+/g, "-")}`;
2667
3796
  db.upsertNode(sessionId, {
2668
3797
  id,
@@ -2671,7 +3800,10 @@ ${infraSummary.substring(0, 12e3)}`;
2671
3800
  discoveredVia: "manual",
2672
3801
  confidence: 1,
2673
3802
  metadata: { ...host ? { host } : {}, ...port ? { port } : {} },
2674
- tags
3803
+ tags,
3804
+ domain,
3805
+ subDomain,
3806
+ qualityScore
2675
3807
  });
2676
3808
  out(` ${green("+")} ${cyan(id)}
2677
3809
  `);
@@ -2692,7 +3824,7 @@ ${infraSummary.substring(0, 12e3)}`;
2692
3824
  `);
2693
3825
  });
2694
3826
  program.command("doctor").description("Check all requirements and cloud CLIs").action(async () => {
2695
- const { execSync: execSync2 } = await import("child_process");
3827
+ const { execSync: execSync3 } = await import("child_process");
2696
3828
  const { existsSync: existsSync3, readFileSync: readFileSync3 } = await import("fs");
2697
3829
  const { join: join3 } = await import("path");
2698
3830
  const out = (s) => process.stdout.write(s);
@@ -2715,7 +3847,7 @@ ${infraSummary.substring(0, 12e3)}`;
2715
3847
  allGood = false;
2716
3848
  }
2717
3849
  try {
2718
- const v = execSync2("claude --version", { stdio: "pipe" }).toString().trim();
3850
+ const v = execSync3("claude --version", { stdio: "pipe" }).toString().trim();
2719
3851
  ok(`Claude CLI ${dim2(v)}`);
2720
3852
  } catch {
2721
3853
  err("Claude CLI not found \u2014 npm i -g @anthropic-ai/claude-code");
@@ -2739,7 +3871,7 @@ ${infraSummary.substring(0, 12e3)}`;
2739
3871
  allGood = false;
2740
3872
  }
2741
3873
  try {
2742
- const v = execSync2("kubectl version --client --short 2>/dev/null || kubectl version --client", { stdio: "pipe" }).toString().split("\n")[0]?.trim() ?? "";
3874
+ const v = execSync3("kubectl version --client --short 2>/dev/null || kubectl version --client", { stdio: "pipe" }).toString().split("\n")[0]?.trim() ?? "";
2743
3875
  ok(`kubectl ${dim2(v || "(client OK)")}`);
2744
3876
  } catch {
2745
3877
  warn(`kubectl not found ${dim2("\u2014 install: https://kubernetes.io/docs/tasks/tools/")}`);
@@ -2751,7 +3883,7 @@ ${infraSummary.substring(0, 12e3)}`;
2751
3883
  ];
2752
3884
  for (const [name, cmd, hint] of cloudClis) {
2753
3885
  try {
2754
- execSync2(cmd, { stdio: "pipe" });
3886
+ execSync3(cmd, { stdio: "pipe" });
2755
3887
  ok(`${name} ${dim2("(cloud scanning available)")}`);
2756
3888
  } catch {
2757
3889
  warn(`${name} not found ${dim2("\u2014 cloud scan skipped | " + hint)}`);
@@ -2763,7 +3895,7 @@ ${infraSummary.substring(0, 12e3)}`;
2763
3895
  ];
2764
3896
  for (const [name, cmd] of localTools) {
2765
3897
  try {
2766
- execSync2(cmd, { stdio: "pipe" });
3898
+ execSync3(cmd, { stdio: "pipe" });
2767
3899
  ok(`${name} ${dim2("(discovery tool)")}`);
2768
3900
  } catch {
2769
3901
  warn(`${name} not found ${dim2("\u2014 discovery without " + name + " will be limited")}`);