metadata-viewer.html 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>VL Project Metadata</title>
  6. <style>
  7. :root {
  8. --bg: #0a0c10; --bg2: #12151c; --bg3: #161a24; --border: #2a3040;
  9. --text: #e8ecf4; --text2: #8892a4;
  10. --c-page: #5ba0ff; --c-section: #38c8d0; --c-comp: #56d8de;
  11. --c-service: #40c060; --c-vtable: #80e898; --c-table: #c090ff;
  12. }
  13. * { margin:0; padding:0; box-sizing:border-box; }
  14. body { background:var(--bg); color:var(--text); font-family:'SF Mono','Fira Code',monospace; font-size:12px; overflow:auto; height:100vh; }
  15. .empty-msg {
  16. position:fixed; top:50%; left:50%; transform:translate(-50%,-50%);
  17. color:var(--text2); font-size:14px; text-align:center; line-height:1.6;
  18. }
  19. .canvas { position:relative; padding:20px; }
  20. /* Column headers */
  21. .col-headers { display:flex; gap:60px; padding:0 40px 10px; border-bottom:1px solid var(--border); margin-bottom:20px; position:sticky; top:0; background:var(--bg); z-index:10; }
  22. .col-header { width:210px; text-align:center; font-size:10px; color:var(--text2); text-transform:uppercase; letter-spacing:1px; padding:8px 0; }
  23. .col-header .col-count { display:inline-block; background:var(--bg3); padding:1px 6px; border-radius:8px; margin-left:4px; font-size:9px; }
  24. /* Nodes */
  25. .node {
  26. position:absolute; width:210px; height:52px; background:var(--bg3); border:1px solid var(--border);
  27. border-radius:6px; cursor:pointer; transition:border-color 0.2s, opacity 0.2s; z-index:2;
  28. display:flex; align-items:center; gap:8px; padding:0 10px;
  29. }
  30. .node:hover { border-color:var(--c-page); }
  31. .node.selected { border-color:var(--c-page); box-shadow:0 0 12px rgba(91,160,255,0.2); }
  32. .node.dimmed { opacity:0.15; }
  33. .node-icon { width:28px; height:28px; border-radius:5px; display:flex; align-items:center; justify-content:center; font-size:8px; font-weight:700; color:#fff; flex-shrink:0; }
  34. .node-info { flex:1; overflow:hidden; }
  35. .node-title { font-size:11px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  36. .node-sub { font-size:9px; color:var(--text2); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  37. .node-badge { font-size:9px; color:var(--text2); background:rgba(255,255,255,0.06); padding:1px 5px; border-radius:8px; flex-shrink:0; }
  38. /* Node type icon backgrounds */
  39. .node-page .node-icon { background:rgba(91,160,255,0.2); color:var(--c-page); }
  40. .node-section .node-icon { background:rgba(56,200,208,0.2); color:var(--c-section); }
  41. .node-comp .node-icon { background:rgba(86,216,222,0.2); color:var(--c-comp); }
  42. .node-service .node-icon { background:rgba(64,192,96,0.2); color:var(--c-service); }
  43. .node-vtable .node-icon { background:rgba(128,232,152,0.2); color:var(--c-vtable); }
  44. .node-table .node-icon { background:rgba(192,144,255,0.2); color:var(--c-table); }
  45. /* Connections (SVG) */
  46. .connections { position:absolute; top:0; left:0; width:100%; height:100%; pointer-events:none; z-index:1; }
  47. .conn { fill:none; stroke-width:1.5; opacity:0.35; transition:opacity 0.2s, stroke-width 0.2s; }
  48. .conn.highlight { opacity:1; stroke-width:2.5; }
  49. .conn-ui { stroke:var(--c-page); }
  50. .conn-comp { stroke:var(--c-comp); }
  51. .conn-service { stroke:var(--c-service); }
  52. .conn-data { stroke:var(--c-vtable); }
  53. /* Stats bar */
  54. .stats-bar { position:fixed; bottom:0; left:0; right:0; background:var(--bg2); border-top:1px solid var(--border); padding:4px 16px; font-size:10px; color:var(--text2); display:flex; gap:16px; z-index:20; }
  55. .stats-bar .stat { display:flex; align-items:center; gap:4px; }
  56. .stats-bar .stat-dot { width:6px; height:6px; border-radius:50%; }
  57. /* Detail panel (on node click) */
  58. .detail-panel {
  59. position:fixed; right:0; top:0; bottom:32px; width:280px; background:var(--bg2);
  60. border-left:1px solid var(--border); z-index:30; overflow-y:auto; padding:12px;
  61. transform:translateX(100%); transition:transform 0.2s;
  62. }
  63. .detail-panel.open { transform:translateX(0); }
  64. .detail-title { font-size:13px; font-weight:700; margin-bottom:8px; color:var(--text); }
  65. .detail-type { font-size:10px; color:var(--text2); margin-bottom:10px; text-transform:uppercase; letter-spacing:0.5px; }
  66. .detail-section { margin-bottom:10px; }
  67. .detail-section-title { font-size:9px; color:var(--text2); text-transform:uppercase; letter-spacing:0.5px; margin-bottom:4px; }
  68. .detail-item { font-size:10px; padding:2px 0; color:var(--text); word-break:break-all; }
  69. .detail-close { position:absolute; top:8px; right:8px; cursor:pointer; color:var(--text2); font-size:14px; }
  70. .detail-close:hover { color:var(--text); }
  71. </style>
  72. </head>
  73. <body>
  74. <div class="empty-msg" id="emptyMsg">No metadata loaded.<br>Generate auxiliary files to see project structure.</div>
  75. <div class="canvas" id="canvas" style="display:none;">
  76. <div class="col-headers" id="colHeaders"></div>
  77. <svg class="connections" id="connSvg"></svg>
  78. <div class="nodes-layer" id="nodesLayer"></div>
  79. </div>
  80. <div class="stats-bar" id="statsBar" style="display:none;"></div>
  81. <div class="detail-panel" id="detailPanel">
  82. <div class="detail-close" onclick="closeDetail()">&times;</div>
  83. <div class="detail-title" id="detailTitle"></div>
  84. <div class="detail-type" id="detailType"></div>
  85. <div id="detailBody"></div>
  86. </div>
  87. <script>
  88. const COL_TYPES = ['page', 'section', 'comp', 'service', 'vtable', 'table'];
  89. const COL_NAMES = ['Apps / Pages', 'Sections', 'Components', 'Services', 'Virtual Tables', 'Tables'];
  90. const COL_ICONS = { page:'PG', section:'SC', comp:'CP', service:'SV', vtable:'VT', table:'TB' };
  91. const COL_COLORS = {
  92. page:'#5ba0ff', section:'#38c8d0', comp:'#56d8de',
  93. service:'#40c060', vtable:'#80e898', table:'#c090ff'
  94. };
  95. const NODE_W = 210, NODE_H = 52, COL_GAP = 60, ROW_GAP = 14, START_X = 40, START_Y = 60;
  96. let nodes = [], connections = [], selectedNodeId = null;
  97. let nodeMap = new Map(); // id → node for O(1) lookups
  98. function parseMeta(meta) {
  99. nodes = []; connections = []; nodeMap.clear();
  100. if (!meta) return;
  101. // --- Extract nodes ---
  102. // Support both auto-extracted format (id/sectionId/consumesServices/methods)
  103. // and manual/AI-generated format (name/section/services/serviceDomains)
  104. // Pages (from apps[].pages[])
  105. if (meta.apps) {
  106. for (const app of meta.apps) {
  107. const appId = app.appId || app.id || app.name;
  108. for (const page of (app.pages || [])) {
  109. const id = page.pageId || page.id || page.name || `${appId}_page`;
  110. nodes.push({ id, type: 'page', label: id, sub: appId, data: page });
  111. // Page → sections: support legacy page.sections + new page.sectionRefs/routeMap
  112. const secRefs = [
  113. ...(page.sections || []),
  114. ...(page.sectionRefs || []),
  115. ...(page.section ? [page.section] : []),
  116. ...Object.values(page.routeMap || {}),
  117. ];
  118. for (const secRef of secRefs) {
  119. let secId;
  120. if (typeof secRef === 'string') {
  121. secId = secRef;
  122. } else if (secRef) {
  123. secId = secRef.sectionId || secRef.id || secRef.name || secRef.ref;
  124. }
  125. if (secId) connections.push({ from: id, to: secId, type: 'ui' });
  126. }
  127. // Page → components: support explicit componentRefs on app pages
  128. for (const compRef of (page.componentRefs || [])) {
  129. const compId = typeof compRef === 'string'
  130. ? compRef
  131. : (compRef.componentId || compRef.id || compRef.name || compRef.ref);
  132. if (compId) connections.push({ from: id, to: compId, type: 'comp' });
  133. }
  134. // Also check page.layout for section references
  135. if (page.layout) {
  136. collectLayoutRefs(page.layout, id, meta);
  137. }
  138. }
  139. }
  140. }
  141. // Sections
  142. if (meta.sections) {
  143. for (const sec of meta.sections) {
  144. const secId = sec.id || sec.sectionId || sec.name;
  145. if (!secId) continue;
  146. nodes.push({ id: secId, type: 'section', label: secId, sub: sec.filePath || sec.file || '', data: sec });
  147. // Section → services: support legacy consumesServices + new servicesUsed blocks
  148. const svcRefs = sec.servicesUsed || sec.consumesServices || sec.services || [];
  149. sec._domainRefs = svcRefs;
  150. for (const svcRef of svcRefs) {
  151. if (svcRef?.services && Array.isArray(svcRef.services)) {
  152. for (const service of svcRef.services) {
  153. const svcId = resolveServiceRefId({
  154. ...service,
  155. domainId: service.domainId || svcRef.domainId,
  156. });
  157. if (svcId) connections.push({ from: secId, to: svcId, type: 'service' });
  158. }
  159. continue;
  160. }
  161. const svcId = resolveServiceRefId(svcRef);
  162. if (svcId) connections.push({ from: secId, to: svcId, type: 'service' });
  163. }
  164. // Section → components (handle both legacy and extractor refs)
  165. const cpRefs = sec.componentRefs || sec.usesComponents || sec.components || [];
  166. for (const cpRef of cpRefs) {
  167. const cpId = typeof cpRef === 'string' ? cpRef : (cpRef.componentId || cpRef.id || cpRef.name);
  168. if (cpId) connections.push({ from: secId, to: cpId, type: 'comp' });
  169. }
  170. }
  171. }
  172. // Components
  173. if (meta.components) {
  174. for (const cp of meta.components) {
  175. const cpId = cp.id || cp.componentId || cp.name;
  176. if (!cpId) continue;
  177. const propCount = cp.props ? (Array.isArray(cp.props) ? cp.props.length : Object.keys(cp.props).length) : 0;
  178. nodes.push({ id: cpId, type: 'comp', label: cpId, sub: cp.filePath || cp.file || '', badge: propCount ? `${propCount}p` : '', data: cp });
  179. }
  180. }
  181. // Services: support both "services" and "serviceDomains" as the top-level key
  182. const serviceDomains = meta.services || meta.serviceDomains || [];
  183. for (const domain of serviceDomains) {
  184. const domId = domain.domainId || domain.id || domain.name;
  185. if (!domId) continue;
  186. // Build vTable nodes first (domain-prefixed IDs)
  187. // virtualTables can be array of objects or array of strings (just names)
  188. const vtNodeIds = [];
  189. for (const vt of (domain.virtualTables || [])) {
  190. let vtName, vtObj;
  191. if (typeof vt === 'string') {
  192. vtName = vt; vtObj = { id: vt };
  193. } else {
  194. vtName = vt.id || vt.name; vtObj = vt;
  195. }
  196. if (!vtName) continue;
  197. const vtId = `${domId}.${vtName}`;
  198. const sourceTable = vtObj.source || vtObj.sourceTable || null;
  199. vtNodeIds.push(vtId);
  200. const fieldsBadge = typeof vtObj.fields === 'string' ? vtObj.fields + 'f' : (vtObj.fields?.length ? `${vtObj.fields.length}f` : '');
  201. nodes.push({ id: vtId, type: 'vtable', label: vtName, sub: `source: ${sourceTable || '?'}`, badge: fieldsBadge, data: vtObj });
  202. // vTable → table
  203. if (sourceTable) connections.push({ from: vtId, to: sourceTable, type: 'data' });
  204. }
  205. // Create one node per method (not per domain)
  206. // Support both "methods" and "services" as the methods array key
  207. const methods = domain.methods || domain.services || [];
  208. if (methods.length > 0) {
  209. for (const m of methods) {
  210. const mId = m.id || m.name;
  211. if (!mId) continue;
  212. const svcNodeId = `${domId}.${mId}`;
  213. const isPublic = m.isPublic || m.visibility === 'public';
  214. nodes.push({ id: svcNodeId, type: 'service', label: mId, sub: domId + (isPublic ? ' (public)' : ''), data: m });
  215. // Each method connects to ALL vTables in its domain
  216. for (const vtId of vtNodeIds) {
  217. connections.push({ from: svcNodeId, to: vtId, type: 'data' });
  218. }
  219. }
  220. } else {
  221. // Fallback: if no methods listed, create a single domain node
  222. nodes.push({ id: domId, type: 'service', label: domId, sub: domain.filePath || domain.file || '', badge: '0m', data: domain });
  223. for (const vtId of vtNodeIds) {
  224. connections.push({ from: domId, to: vtId, type: 'data' });
  225. }
  226. }
  227. }
  228. // Tables: support unified ProjectMeta (dataSchema.tables) and legacy extractor format (database.tables)
  229. const tables = meta.dataSchema?.tables || meta.tables || meta.database?.tables || [];
  230. const tableIds = new Set();
  231. for (const tbl of tables) {
  232. const tblId = tbl.id || tbl.name;
  233. if (!tblId) continue;
  234. tableIds.add(tblId);
  235. const fieldCount = tbl.fields?.length || tbl.columns?.length || 0;
  236. nodes.push({ id: tblId, type: 'table', label: tblId, sub: 'table', badge: fieldCount ? `${fieldCount}f` : '', data: tbl });
  237. }
  238. // Infer missing VT→Table connections: if source is missing, try to match VT alias/name to table names
  239. for (const domain of serviceDomains) {
  240. const domId = domain.domainId || domain.id || domain.name;
  241. if (!domId) continue;
  242. for (const vt of (domain.virtualTables || [])) {
  243. const vtName = typeof vt === 'string' ? vt : (vt.id || vt.name);
  244. if (!vtName) continue;
  245. const vtObj = typeof vt === 'string' ? {} : vt;
  246. const vtId = `${domId}.${vtName}`;
  247. if (vtObj.source || vtObj.sourceTable) continue; // already has explicit source — connection created above
  248. // Try to infer: match alias or vt name against table names (case-insensitive)
  249. const alias = vtObj.alias || '';
  250. for (const tblId of tableIds) {
  251. const tblLower = tblId.toLowerCase();
  252. if (alias.toLowerCase().includes(tblLower) || tblLower.includes(alias.toLowerCase().replace('table', ''))
  253. || vtName.toLowerCase().includes(tblLower) || tblLower.includes(vtName.toLowerCase().replace('list', '').replace('table', ''))) {
  254. connections.push({ from: vtId, to: tblId, type: 'data' });
  255. // Update sub text to show inferred source
  256. const vtNode = nodes.find(n => n.id === vtId);
  257. if (vtNode) vtNode.sub = `source: ${tblId} (inferred)`;
  258. break;
  259. }
  260. }
  261. }
  262. }
  263. // Deduplicate nodes by id
  264. const seen = new Set();
  265. nodes = nodes.filter(n => { if (seen.has(n.id)) return false; seen.add(n.id); return true; });
  266. // Build lookup map
  267. nodeMap = new Map(nodes.map(n => [n.id, n]));
  268. // Resolve domain-level section→service refs to per-method nodes
  269. // e.g., if section references "Match" but nodes are "Match.GetSports", "Match.GetLiveMatches"
  270. const expandedConns = [];
  271. for (const c of connections) {
  272. if (c.type === 'service' && !nodeMap.has(c.to)) {
  273. // Target doesn't exist as a node — look for per-method nodes under this domain
  274. const domainPrefix = c.to + '.';
  275. const methodNodes = nodes.filter(n => n.type === 'service' && n.id.startsWith(domainPrefix));
  276. if (methodNodes.length > 0) {
  277. for (const mn of methodNodes) {
  278. expandedConns.push({ from: c.from, to: mn.id, type: 'service' });
  279. }
  280. continue; // Skip original connection
  281. }
  282. }
  283. expandedConns.push(c);
  284. }
  285. connections = expandedConns;
  286. // Filter connections: only keep those where both from and to nodes exist
  287. connections = connections.filter(c => nodeMap.has(c.from) && nodeMap.has(c.to));
  288. // Deduplicate connections
  289. const connSeen = new Set();
  290. connections = connections.filter(c => {
  291. const key = `${c.from}→${c.to}`;
  292. if (connSeen.has(key)) return false;
  293. connSeen.add(key); return true;
  294. });
  295. layoutAndRender();
  296. }
  297. function resolveServiceRefId(svcRef) {
  298. if (!svcRef) return '';
  299. if (typeof svcRef === 'string') return svcRef;
  300. const direct = svcRef.serviceId || svcRef.id || svcRef.name || '';
  301. if (direct.includes('.')) return direct;
  302. const domainId = svcRef.domainId || svcRef.domain || svcRef.domainName || '';
  303. const methodId = svcRef.serviceName || svcRef.method || direct;
  304. if (domainId && methodId) return `${domainId}.${methodId}`;
  305. return domainId || methodId || '';
  306. }
  307. /** Recursively collect section/component refs from page layout tree */
  308. function collectLayoutRefs(layoutNodes, pageId, meta) {
  309. if (!Array.isArray(layoutNodes)) return;
  310. const sectionIds = new Set((meta?.sections || []).map(s => s.id || s.sectionId).filter(Boolean));
  311. const compIds = new Set((meta?.components || []).map(c => c.id || c.componentId).filter(Boolean));
  312. _collectLayoutRefsInner(layoutNodes, pageId, sectionIds, compIds);
  313. }
  314. function _collectLayoutRefsInner(layoutNodes, pageId, sectionIds, compIds) {
  315. if (!Array.isArray(layoutNodes)) return;
  316. for (const node of layoutNodes) {
  317. if (node.sectionId) connections.push({ from: pageId, to: node.sectionId, type: 'ui' });
  318. if (node.componentId) connections.push({ from: pageId, to: node.componentId, type: 'comp' });
  319. // Handle layout nodes that use "ref" field (e.g., { "ref": "AgentNavSection", "as": "agentNav" })
  320. if (node.ref) {
  321. const ref = node.ref;
  322. if (sectionIds.has(ref) || ref.endsWith('Section')) {
  323. connections.push({ from: pageId, to: ref, type: 'ui' });
  324. } else if (compIds.has(ref)) {
  325. connections.push({ from: pageId, to: ref, type: 'comp' });
  326. } else {
  327. // Unknown target type — default to ui connection so it isn't lost
  328. connections.push({ from: pageId, to: ref, type: 'ui' });
  329. }
  330. }
  331. if (node.children) _collectLayoutRefsInner(node.children, pageId, sectionIds, compIds);
  332. }
  333. }
  334. function layoutAndRender() {
  335. // Auto-layout: 6 columns
  336. const colNodes = {};
  337. for (const t of COL_TYPES) colNodes[t] = [];
  338. for (const n of nodes) {
  339. if (colNodes[n.type]) colNodes[n.type].push(n);
  340. }
  341. // Sort nodes within each column by connection order for cleaner routing
  342. for (const type of COL_TYPES) {
  343. colNodes[type].sort((a, b) => {
  344. const aConns = connections.filter(c => c.to === a.id || c.from === a.id).length;
  345. const bConns = connections.filter(c => c.to === b.id || c.from === b.id).length;
  346. return bConns - aConns; // Most connected first
  347. });
  348. }
  349. let maxY = 0;
  350. for (let ci = 0; ci < COL_TYPES.length; ci++) {
  351. const type = COL_TYPES[ci];
  352. const x = START_X + ci * (NODE_W + COL_GAP);
  353. for (let ri = 0; ri < colNodes[type].length; ri++) {
  354. colNodes[type][ri].x = x;
  355. colNodes[type][ri].y = START_Y + ri * (NODE_H + ROW_GAP);
  356. maxY = Math.max(maxY, colNodes[type][ri].y + NODE_H);
  357. }
  358. }
  359. // Auto-size canvas
  360. const maxX = START_X + COL_TYPES.length * (NODE_W + COL_GAP) + 40;
  361. const canvasH = maxY + 80;
  362. const canvas = $('canvas');
  363. canvas.style.minWidth = maxX + 'px';
  364. canvas.style.minHeight = canvasH + 'px';
  365. // Set explicit SVG dimensions to prevent connection clipping
  366. const svg = $('connSvg');
  367. svg.setAttribute('width', maxX);
  368. svg.setAttribute('height', canvasH);
  369. render();
  370. }
  371. function render() {
  372. $('emptyMsg').style.display = 'none';
  373. $('canvas').style.display = 'block';
  374. $('statsBar').style.display = 'flex';
  375. // Column headers with counts
  376. const hdr = $('colHeaders');
  377. hdr.innerHTML = '';
  378. for (let i = 0; i < COL_NAMES.length; i++) {
  379. const count = nodes.filter(n => n.type === COL_TYPES[i]).length;
  380. const div = document.createElement('div');
  381. div.className = 'col-header';
  382. div.innerHTML = `${COL_NAMES[i]}<span class="col-count">${count}</span>`;
  383. hdr.appendChild(div);
  384. }
  385. renderNodes();
  386. renderConnections();
  387. renderStats();
  388. }
  389. function renderNodes() {
  390. const layer = $('nodesLayer');
  391. layer.innerHTML = '';
  392. for (const node of nodes) {
  393. if (node.x === undefined) continue;
  394. const div = document.createElement('div');
  395. div.className = `node node-${node.type}` + (node.id === selectedNodeId ? ' selected' : '');
  396. div.id = `meta-${node.id}`;
  397. div.style.left = node.x + 'px';
  398. div.style.top = node.y + 'px';
  399. div.innerHTML = `
  400. <div class="node-icon">${COL_ICONS[node.type] || '?'}</div>
  401. <div class="node-info">
  402. <div class="node-title">${esc(node.label)}</div>
  403. <div class="node-sub">${esc(node.sub || '')}</div>
  404. </div>
  405. ${node.badge ? `<div class="node-badge">${esc(node.badge)}</div>` : ''}
  406. `;
  407. div.onclick = () => selectNode(node.id);
  408. div.onmouseenter = () => highlightRelated(node.id);
  409. div.onmouseleave = () => clearHighlight();
  410. layer.appendChild(div);
  411. }
  412. }
  413. function renderConnections() {
  414. const svg = $('connSvg');
  415. svg.innerHTML = '';
  416. for (const conn of connections) {
  417. const fromNode = nodeMap.get(conn.from);
  418. const toNode = nodeMap.get(conn.to);
  419. if (!fromNode || !toNode || fromNode.x === undefined || toNode.x === undefined) continue;
  420. // Determine connection direction
  421. const goesRight = toNode.x > fromNode.x;
  422. const sameColumn = Math.abs(toNode.x - fromNode.x) < NODE_W;
  423. let x1, y1, x2, y2, d;
  424. if (sameColumn) {
  425. // Same column: route from bottom to top
  426. x1 = fromNode.x + NODE_W / 2;
  427. y1 = fromNode.y + NODE_H;
  428. x2 = toNode.x + NODE_W / 2;
  429. y2 = toNode.y;
  430. const offset = 30;
  431. d = `M ${x1} ${y1} C ${x1 + offset} ${y1 + 20}, ${x2 + offset} ${y2 - 20}, ${x2} ${y2}`;
  432. } else if (goesRight) {
  433. // Normal left-to-right: right edge → left edge
  434. x1 = fromNode.x + NODE_W;
  435. y1 = fromNode.y + NODE_H / 2;
  436. x2 = toNode.x;
  437. y2 = toNode.y + NODE_H / 2;
  438. const dx = Math.abs(x2 - x1);
  439. const cp = Math.min(dx * 0.4, 80);
  440. d = `M ${x1} ${y1} C ${x1 + cp} ${y1}, ${x2 - cp} ${y2}, ${x2} ${y2}`;
  441. } else {
  442. // Backward (right-to-left): use curved arc over the top
  443. x1 = fromNode.x;
  444. y1 = fromNode.y + NODE_H / 2;
  445. x2 = toNode.x + NODE_W;
  446. y2 = toNode.y + NODE_H / 2;
  447. const midY = Math.min(fromNode.y, toNode.y) - 40;
  448. d = `M ${x1} ${y1} C ${x1 - 40} ${midY}, ${x2 + 40} ${midY}, ${x2} ${y2}`;
  449. }
  450. const connClass = { ui: 'conn-ui', comp: 'conn-comp', service: 'conn-service', data: 'conn-data' }[conn.type] || 'conn-ui';
  451. const path = svgEl('path');
  452. path.setAttribute('class', `conn ${connClass}`);
  453. path.setAttribute('d', d);
  454. path.dataset.from = conn.from;
  455. path.dataset.to = conn.to;
  456. svg.appendChild(path);
  457. }
  458. }
  459. function renderStats() {
  460. const counts = {};
  461. for (const t of COL_TYPES) counts[t] = nodes.filter(n => n.type === t).length;
  462. const totalConns = connections.length;
  463. $('statsBar').innerHTML = COL_TYPES.map(t =>
  464. `<div class="stat"><div class="stat-dot" style="background:${COL_COLORS[t]}"></div>${COL_NAMES[COL_TYPES.indexOf(t)]}: ${counts[t]}</div>`
  465. ).join('') + `<div class="stat" style="margin-left:auto">${totalConns} connections</div>`;
  466. }
  467. function selectNode(id) {
  468. selectedNodeId = id;
  469. renderNodes();
  470. renderConnections();
  471. showDetail(id);
  472. window.parent.postMessage({ type: 'metaNodeClick', nodeType: nodeMap.get(id)?.type, nodeName: id }, '*');
  473. }
  474. function showDetail(id) {
  475. const node = nodeMap.get(id);
  476. if (!node) return;
  477. const panel = $('detailPanel');
  478. $('detailTitle').textContent = node.label;
  479. $('detailType').textContent = node.type;
  480. const body = $('detailBody');
  481. body.innerHTML = '';
  482. // Sub info
  483. if (node.sub) {
  484. addDetailSection(body, 'Path', [node.sub]);
  485. }
  486. // Connected nodes
  487. const incoming = connections.filter(c => c.to === id).map(c => `← ${c.from}` + (c.label ? ` (${c.label})` : ''));
  488. const outgoing = connections.filter(c => c.from === id).map(c => `→ ${c.to}` + (c.label ? ` (${c.label})` : ''));
  489. if (incoming.length) addDetailSection(body, 'Incoming', incoming);
  490. if (outgoing.length) addDetailSection(body, 'Outgoing', outgoing);
  491. // Type-specific details
  492. const d = node.data || {};
  493. if (node.type === 'service' && d.methods) {
  494. addDetailSection(body, 'Methods', d.methods.map(m => `${m.id || m.name}()`));
  495. }
  496. if (node.type === 'table' && (d.fields || d.columns)) {
  497. const fields = d.fields || d.columns || [];
  498. addDetailSection(body, 'Fields', fields.map(f => `${f.name || f.id}: ${f.type || '?'}`));
  499. }
  500. if (node.type === 'vtable' && d.fields) {
  501. addDetailSection(body, 'Fields', d.fields.map(f => `${f.name || f.id}: ${f.type || '?'}`));
  502. }
  503. if (node.type === 'comp' && d.props) {
  504. const props = Array.isArray(d.props) ? d.props.map(p => typeof p === 'string' ? p : `${p.name}: ${p.type || '?'}`) : Object.entries(d.props).map(([k,v]) => `${k}: ${typeof v === 'string' ? v : v.type || '?'}`);
  505. addDetailSection(body, 'Props', props);
  506. }
  507. panel.classList.add('open');
  508. }
  509. function addDetailSection(parent, title, items) {
  510. const sec = document.createElement('div');
  511. sec.className = 'detail-section';
  512. sec.innerHTML = `<div class="detail-section-title">${esc(title)}</div>` +
  513. items.map(i => `<div class="detail-item">${esc(i)}</div>`).join('');
  514. parent.appendChild(sec);
  515. }
  516. function closeDetail() {
  517. $('detailPanel').classList.remove('open');
  518. selectedNodeId = null;
  519. renderNodes();
  520. }
  521. function highlightRelated(id) {
  522. // Find connected nodes (multi-hop for better visibility)
  523. const related = new Set([id]);
  524. for (const c of connections) {
  525. if (c.from === id) related.add(c.to);
  526. if (c.to === id) related.add(c.from);
  527. }
  528. // Dim unrelated nodes
  529. document.querySelectorAll('.node').forEach(el => {
  530. const nid = el.id.replace('meta-', '');
  531. el.classList.toggle('dimmed', !related.has(nid));
  532. });
  533. // Highlight related connections
  534. document.querySelectorAll('.conn').forEach(el => {
  535. const isRelated = el.dataset.from === id || el.dataset.to === id;
  536. el.classList.toggle('highlight', isRelated);
  537. });
  538. }
  539. function clearHighlight() {
  540. document.querySelectorAll('.node.dimmed').forEach(el => el.classList.remove('dimmed'));
  541. document.querySelectorAll('.conn.highlight').forEach(el => el.classList.remove('highlight'));
  542. }
  543. // ===== PostMessage API =====
  544. window.addEventListener('message', (e) => {
  545. if (!e.data?.type) return;
  546. switch (e.data.type) {
  547. case 'loadMetadata':
  548. parseMeta(e.data.data);
  549. break;
  550. case 'highlightNode': {
  551. const el = document.getElementById(`meta-${e.data.nodeId}`);
  552. if (el) {
  553. selectNode(e.data.nodeId);
  554. el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
  555. }
  556. break;
  557. }
  558. }
  559. });
  560. // Notify parent we're ready
  561. window.parent.postMessage({ type: 'ready' }, '*');
  562. // ===== Utilities =====
  563. function $(id) { return document.getElementById(id); }
  564. function esc(s) { return s ? String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') : ''; }
  565. function svgEl(tag) { return document.createElementNS('http://www.w3.org/2000/svg', tag); }
  566. </script>
  567. </body>
  568. </html>