@ctxo/cli 0.7.2 → 0.8.1

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.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ JsonIndexReader
4
+ } from "./chunk-2TVHKCCU.js";
5
+ export {
6
+ JsonIndexReader
7
+ };
8
+ //# sourceMappingURL=json-index-reader-XOEE6VMD.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ctxo/cli",
3
- "version": "0.7.2",
3
+ "version": "0.8.1",
4
4
  "description": "MCP server delivering dependency-aware, history-enriched context for codebases",
5
5
  "type": "module",
6
6
  "engines": {
@@ -31,6 +31,8 @@
31
31
  "@clack/prompts": "^1.2.0",
32
32
  "@modelcontextprotocol/sdk": "^1.12.1",
33
33
  "chokidar": "^5.0.0",
34
+ "graphology": "^0.26.0",
35
+ "graphology-communities-louvain": "^2.0.2",
34
36
  "picocolors": "^1.1.1",
35
37
  "picomatch": "^4.0.2",
36
38
  "simple-git": "^3.27.0",
@@ -49,8 +51,8 @@
49
51
  "typescript": "^5.8.3",
50
52
  "typescript-eslint": "^8.57.2",
51
53
  "vitest": "^3.1.3",
52
- "@ctxo/lang-csharp": "0.7.2",
53
54
  "@ctxo/lang-go": "0.8.0",
55
+ "@ctxo/lang-csharp": "0.7.2",
54
56
  "@ctxo/lang-typescript": "0.7.1"
55
57
  },
56
58
  "repository": {
@@ -1,5 +1,5 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en">
2
+ <html lang="en" data-theme="light">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -126,6 +126,22 @@
126
126
  .banner { padding: 6px 12px; background: rgba(78,121,167,0.1); border-bottom: 1px solid var(--border); font-size: 11px; color: #4E79A7; display: none; }
127
127
  .banner.visible { display: block; }
128
128
 
129
+ /* First-load info modal */
130
+ #infoModal { position: fixed; inset: 0; background: rgba(0,0,0,0.55); display: none; align-items: center; justify-content: center; z-index: 100; padding: 24px; }
131
+ #infoModal.visible { display: flex; }
132
+ #infoModal .card { background: var(--bg2); color: var(--text); border: 1px solid var(--border); border-radius: 10px; padding: 24px 28px; max-width: 560px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.35); font-size: 13px; line-height: 1.55; }
133
+ #infoModal h2 { font-size: 16px; font-weight: 700; margin-bottom: 12px; color: var(--name); }
134
+ #infoModal .example { display: flex; gap: 12px; align-items: center; padding: 10px 12px; background: var(--input-bg); border: 1px solid var(--input-border); border-radius: 6px; margin: 12px 0; }
135
+ #infoModal .example .dot { width: 28px; height: 28px; border-radius: 50%; flex: 0 0 auto; }
136
+ #infoModal .example .text { font-size: 12px; color: var(--text2); }
137
+ #infoModal p { margin-bottom: 10px; color: var(--text2); }
138
+ #infoModal strong { color: var(--name); font-weight: 600; }
139
+ #infoModal code { background: var(--input-bg); padding: 1px 5px; border-radius: 3px; font-family: ui-monospace, monospace; font-size: 11px; color: var(--name); }
140
+ #infoModal .actions { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; gap: 12px; }
141
+ #infoModal .actions label { font-size: 11px; color: var(--text3); display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
142
+ #infoModal button { appearance: none; background: #4E79A7; color: #fff; border: 0; padding: 8px 18px; border-radius: 6px; font: inherit; font-size: 12px; font-weight: 500; cursor: pointer; }
143
+ #infoModal button:hover { background: #3f6388; }
144
+
129
145
  ::-webkit-scrollbar { width: 5px; }
130
146
  ::-webkit-scrollbar-track { background: transparent; }
131
147
  ::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 3px; }
@@ -133,6 +149,24 @@
133
149
  </head>
134
150
  <body>
135
151
 
152
+ <div id="infoModal" role="dialog" aria-labelledby="infoTitle">
153
+ <div class="card">
154
+ <h2 id="infoTitle">Layer vs Community — two views of the same graph</h2>
155
+ <p><strong>Layer</strong> (node fill) — the role you <em>designed</em>. Folder-based: <code>core/</code> is Domain, <code>adapters/</code> is Adapter, <code>__tests__/</code> is Test, etc. Answers <em>"where does this code live in my intended architecture?"</em></p>
156
+ <p><strong>Community</strong> (node border) — the modules the dependency graph <em>actually shows</em>. Louvain clustering finds tightly-connected groups of symbols. Answers <em>"how does the code really hang together?"</em></p>
157
+ <div class="example">
158
+ <div class="dot" style="background:#4E79A7;border:3px solid #F28E2B"></div>
159
+ <div class="text"><strong>Dual mode.</strong> Blue fill + orange border = this symbol is labelled Domain but clusters with an Adapter community. That mismatch is the signal: either the layer rule is wrong, or the code has leaked across the boundary you drew.</div>
160
+ </div>
161
+ <p>Red edges are <strong>boundary violations</strong> — dependencies that cross communities and are rare (or unprecedented) in snapshot history. That's where architectural drift shows up first.</p>
162
+ <p><strong>Use it:</strong> skim in Dual for mismatches, switch to Layer to orient, switch to Community to find the de-facto modules when refactoring.</p>
163
+ <div class="actions">
164
+ <label><input type="checkbox" id="infoDontShow" /> Don't show again</label>
165
+ <button id="infoClose">Got it</button>
166
+ </div>
167
+ </div>
168
+ </div>
169
+
136
170
  <div id="graph-wrap">
137
171
  <div id="graph"></div>
138
172
  <div id="graph-overlay">
@@ -142,6 +176,12 @@
142
176
  </div>
143
177
  <div class="legend-body" id="layerBody"></div>
144
178
  </div>
179
+ <div class="legend-panel" id="communityPanel">
180
+ <div class="legend-header" id="communityHeader">
181
+ Communities <span class="arrow open">&#9660;</span>
182
+ </div>
183
+ <div class="legend-body" id="communityBody"></div>
184
+ </div>
145
185
  <div class="legend-panel" id="edgePanel">
146
186
  <div class="legend-header" id="edgeHeader">
147
187
  Edges <span class="arrow open">&#9660;</span>
@@ -173,6 +213,22 @@
173
213
  <span class="tip-icon" title="Show only unreachable symbols with zero importers or cascading dead dependencies">?</span>
174
214
  </label>
175
215
  </div>
216
+ <div id="color-mode-row" style="display:flex;align-items:center;gap:6px;margin-top:4px;font-size:10px;color:var(--text4);flex-wrap:wrap">
217
+ <span>Color by</span>
218
+ <label style="display:inline-flex;align-items:center;gap:3px;cursor:pointer;color:var(--text2)">
219
+ <input type="radio" name="colorMode" value="dual" checked style="margin:0" />
220
+ Dual (fill = Layer, border = Community)
221
+ </label>
222
+ <label style="display:inline-flex;align-items:center;gap:3px;cursor:pointer;color:var(--text2)">
223
+ <input type="radio" name="colorMode" value="layer" style="margin:0" />
224
+ Layer
225
+ </label>
226
+ <label style="display:inline-flex;align-items:center;gap:3px;cursor:pointer;color:var(--text2)">
227
+ <input type="radio" name="colorMode" value="community" style="margin:0" />
228
+ Community
229
+ </label>
230
+ <button type="button" id="infoBtn" style="background:transparent;border:1px solid var(--input-border);color:var(--text2);font-size:10px;padding:2px 6px;border-radius:4px;cursor:pointer">What's the difference?</button>
231
+ </div>
176
232
  <div id="label-row" style="display:flex;align-items:center;gap:6px;margin-top:4px">
177
233
  <span style="font-size:10px;color:var(--text4)">Labels</span>
178
234
  <input type="range" id="labelSlider" min="0" max="100" value="15" style="flex:1;accent-color:#4E79A7;height:14px" />
@@ -213,6 +269,20 @@ var LAYER_COLORS = {
213
269
  Unknown: '#BAB0AC' // grey
214
270
  };
215
271
 
272
+ // Deterministic palette for community coloring. Wraps at PALETTE.length
273
+ // so repeats are visually distant enough in practice.
274
+ var COMMUNITY_PALETTE = [
275
+ '#4E79A7', '#F28E2B', '#59A14F', '#EDC948', '#B07AA1',
276
+ '#E15759', '#76B7B2', '#FF9DA7', '#9C755F', '#BAB0AC',
277
+ '#6B9AC4', '#D37295', '#8CD17D', '#F1CE63', '#A173D1'
278
+ ];
279
+ var COMMUNITY_UNASSIGNED = '#555866';
280
+
281
+ function communityColor(id) {
282
+ if (id < 0) return COMMUNITY_UNASSIGNED;
283
+ return COMMUNITY_PALETTE[((id % COMMUNITY_PALETTE.length) + COMMUNITY_PALETTE.length) % COMMUNITY_PALETTE.length];
284
+ }
285
+
216
286
  var EDGE_COLORS = {
217
287
  imports: { color: '#4E79A7', opacity: 0.6 },
218
288
  calls: { color: '#59A14F', opacity: 0.7 },
@@ -221,6 +291,21 @@ var EDGE_COLORS = {
221
291
  uses: { color: '#BAB0AC', opacity: 0.35 }
222
292
  };
223
293
 
294
+ // Boundary violations override edge styling. Red for "this edge crosses
295
+ // community boundaries and is rarely/never seen in the snapshot history".
296
+ var VIOLATION_COLOR_HIGH = '#E15759'; // high severity (never crossed before)
297
+ var VIOLATION_COLOR_MEDIUM = '#F28E2B'; // medium (crossed <=2 times historically)
298
+
299
+ // Color mode: 'dual' | 'layer' | 'community'.
300
+ // 'dual' (default) encodes BOTH views on every node:
301
+ // - node fill color = architectural Layer (designed role)
302
+ // - node border color = Louvain Community (observed clustering)
303
+ // This makes mismatches visible at a glance: a community whose nodes span
304
+ // multiple fill colors means an observed module crosses designed layers.
305
+ // Users can collapse to a single encoding via the toggle.
306
+ var colorMode = 'dual';
307
+ var hiddenCommunities = new Set();
308
+
224
309
  // DOM helper
225
310
  function el(tag, attrs, children) {
226
311
  var e = document.createElement(tag);
@@ -272,7 +357,9 @@ function init() {
272
357
  // Build vis.js nodes
273
358
  var visNodes = [];
274
359
  data.nodes.forEach(function(n) {
275
- var color = LAYER_COLORS[n.layer] || '#BAB0AC';
360
+ var communityId = typeof n.communityId === 'number' ? n.communityId : -1;
361
+ var communityLabel = n.communityLabel || 'unassigned';
362
+ var colors = resolveNodeColors(n, communityId);
276
363
  var size = 10 + 30 * (n.pageRank / maxPR);
277
364
  var prNorm = n.pageRank / maxPR;
278
365
  var showLabel = prNorm >= labelThreshold;
@@ -280,22 +367,30 @@ function init() {
280
367
  var nodeObj = {
281
368
  id: n.id,
282
369
  label: showLabel ? n.name : '',
283
- title: n.name + '\n' + n.file + '\n' + n.kind + ' | ' + n.layer + '\nPageRank: ' + n.pageRank.toFixed(6) + '\nIn: ' + n.inDegree + ' Out: ' + n.outDegree + (n.isDead ? '\n[DEAD CODE]' : ''),
370
+ title:
371
+ n.name + '\n' +
372
+ n.file + '\n' +
373
+ n.kind + ' | ' + n.layer + ' | ' + communityLabel + '\n' +
374
+ 'PageRank: ' + n.pageRank.toFixed(6) + '\n' +
375
+ 'In: ' + n.inDegree + ' Out: ' + n.outDegree +
376
+ (n.isDead ? '\n[DEAD CODE]' : ''),
284
377
  size: size,
285
378
  color: {
286
- background: color,
287
- border: color,
288
- highlight: { background: '#ffffff', border: color },
289
- hover: { background: '#ffffff', border: color }
379
+ background: colors.fill,
380
+ border: colors.border,
381
+ highlight: { background: '#ffffff', border: colors.border },
382
+ hover: { background: '#ffffff', border: colors.border }
290
383
  },
291
- borderWidth: n.isDead ? 3 : 1.5,
292
- borderWidthSelected: 3,
293
- font: { color: '#ffffff', size: showLabel ? 12 : 0 },
384
+ borderWidth: colors.borderWidth,
385
+ borderWidthSelected: Math.max(colors.borderWidth, 3),
386
+ font: { color: '#1a1d24', size: showLabel ? 12 : 0 },
294
387
  // Store metadata
295
388
  _kind: n.kind,
296
389
  _file: n.file,
297
390
  _startLine: n.startLine,
298
391
  _layer: n.layer,
392
+ _community: communityId,
393
+ _communityLabel: communityLabel,
299
394
  _pageRank: n.pageRank,
300
395
  _isDead: n.isDead,
301
396
  _deadConfidence: n.deadConfidence,
@@ -308,13 +403,12 @@ function init() {
308
403
  _prNorm: prNorm
309
404
  };
310
405
 
406
+ // Dead/antipattern visuals are handled by resolveNodeColors (border
407
+ // override) but the dashed stroke + faded opacity stay here since they
408
+ // are independent of color.
311
409
  if (n.isDead) {
312
410
  nodeObj.shapeProperties = { borderDashes: [6, 3] };
313
- nodeObj.color.border = '#E15759';
314
411
  nodeObj.opacity = 0.5;
315
- } else if (n.hasAntiPattern) {
316
- nodeObj.color.border = '#F28E2B';
317
- nodeObj.borderWidth = 2.5;
318
412
  }
319
413
 
320
414
  allNodes[n.id] = nodeObj;
@@ -325,16 +419,27 @@ function init() {
325
419
  data.edges.forEach(function(e, i) {
326
420
  var ec = EDGE_COLORS[e.kind] || EDGE_COLORS.uses;
327
421
  var isWeak = (e.kind === 'uses' || e.kind === 'imports');
422
+ var violation = e.violationSeverity;
423
+ var baseColor = violation
424
+ ? (violation === 'high' ? VIOLATION_COLOR_HIGH : VIOLATION_COLOR_MEDIUM)
425
+ : ec.color;
426
+ var baseOpacity = violation ? 0.95 : ec.opacity;
427
+ var baseWidth = violation ? (violation === 'high' ? 3 : 2.5) : (isWeak ? 1 : 2);
328
428
  var edgeObj = {
329
429
  id: 'e' + i,
330
430
  from: e.source,
331
431
  to: e.target,
332
432
  arrows: { to: { enabled: true, scaleFactor: 0.5 } },
333
- color: { color: ec.color, opacity: ec.opacity, highlight: ec.color, hover: ec.color },
334
- width: isWeak ? 1 : 2,
335
- dashes: e.kind === 'uses',
433
+ color: { color: baseColor, opacity: baseOpacity, highlight: baseColor, hover: baseColor },
434
+ width: baseWidth,
435
+ dashes: violation === 'medium' ? [8, 4] : (e.kind === 'uses'),
336
436
  smooth: { type: 'continuous', roundness: 0.2 },
337
437
  selectionWidth: 3,
438
+ _violationSeverity: violation || null,
439
+ _edgeBaseColor: ec.color,
440
+ _edgeBaseOpacity: ec.opacity,
441
+ _edgeBaseWidth: isWeak ? 1 : 2,
442
+ _edgeDashesDefault: e.kind === 'uses',
338
443
  _kind: e.kind
339
444
  };
340
445
  allEdges.push(edgeObj);
@@ -398,12 +503,26 @@ function init() {
398
503
 
399
504
  // Build UI
400
505
  buildLayerLegend(data);
506
+ buildCommunityLegend(data);
401
507
  buildEdgeLegend();
402
508
  setupSearch(data);
403
509
  updateStats();
404
510
 
405
- // Theme toggle
406
- document.getElementById('themeToggle').addEventListener('click', function() {
511
+ // Color mode toggle (Layer / Community)
512
+ document.querySelectorAll('input[name="colorMode"]').forEach(function(radio) {
513
+ radio.addEventListener('change', function() {
514
+ if (!this.checked) return;
515
+ colorMode = this.value;
516
+ applyColorMode();
517
+ });
518
+ });
519
+
520
+ // Theme state — default is light, set on <html data-theme="light">.
521
+ // Theme toggle flips between 'light' and unset (dark).
522
+ var themeBtn = document.getElementById('themeToggle');
523
+ themeBtn.textContent = '\u2600'; // sun visible in light mode → click to go dark
524
+ document.getElementById('graph').style.background = '#f5f6f8';
525
+ themeBtn.addEventListener('click', function() {
407
526
  var current = document.documentElement.getAttribute('data-theme');
408
527
  var isLight = current === 'light';
409
528
  var next = isLight ? null : 'light';
@@ -424,6 +543,26 @@ function init() {
424
543
  document.getElementById('graph').style.background = bgColor;
425
544
  });
426
545
 
546
+ // First-load info modal (explains Layer vs Community). Dismissed forever
547
+ // via "Don't show again" checkbox (localStorage flag).
548
+ var infoModal = document.getElementById('infoModal');
549
+ var infoClose = document.getElementById('infoClose');
550
+ var infoDontShow = document.getElementById('infoDontShow');
551
+ var infoBtn = document.getElementById('infoBtn');
552
+ function showInfoModal() { infoModal.classList.add('visible'); }
553
+ function hideInfoModal() {
554
+ infoModal.classList.remove('visible');
555
+ if (infoDontShow.checked) {
556
+ try { localStorage.setItem('ctxo-vis-info-seen', '1'); } catch (e) {}
557
+ }
558
+ }
559
+ infoClose.addEventListener('click', hideInfoModal);
560
+ infoModal.addEventListener('click', function(e) { if (e.target === infoModal) hideInfoModal(); });
561
+ infoBtn.addEventListener('click', showInfoModal);
562
+ try {
563
+ if (localStorage.getItem('ctxo-vis-info-seen') !== '1') showInfoModal();
564
+ } catch (e) { showInfoModal(); }
565
+
427
566
  // Anti-pattern filter
428
567
  document.getElementById('antiPatternToggle').querySelector('input').addEventListener('change', function() {
429
568
  applyNodeFilters();
@@ -641,6 +780,133 @@ function clearNodeInfo() {
641
780
  });
642
781
  }
643
782
 
783
+ // Color resolution: picks fill + border + width based on the current
784
+ // colorMode plus special cases (dead, anti-pattern).
785
+ // dual → fill = Layer color, border = Community color
786
+ // layer → both fill and border = Layer color
787
+ // community → both fill and border = Community color
788
+ // Special cases always win on the border:
789
+ // isDead → dashed red border
790
+ // antiPattern → solid orange border
791
+ function resolveNodeColors(n, communityId) {
792
+ var layerColor = LAYER_COLORS[n.layer] || '#BAB0AC';
793
+ var commColor = communityColor(communityId);
794
+
795
+ var fill, border, borderWidth;
796
+ if (colorMode === 'community') {
797
+ fill = commColor;
798
+ border = commColor;
799
+ borderWidth = 1.5;
800
+ } else if (colorMode === 'layer') {
801
+ fill = layerColor;
802
+ border = layerColor;
803
+ borderWidth = 1.5;
804
+ } else {
805
+ // dual
806
+ fill = layerColor;
807
+ border = commColor;
808
+ borderWidth = 2.5;
809
+ }
810
+
811
+ if (n.isDead) {
812
+ border = '#E15759';
813
+ borderWidth = 3;
814
+ } else if (n.hasAntiPattern) {
815
+ border = '#F28E2B';
816
+ borderWidth = 2.5;
817
+ }
818
+
819
+ return { fill: fill, border: border, borderWidth: borderWidth };
820
+ }
821
+
822
+ function applyColorMode() {
823
+ var updates = [];
824
+ for (var id in allNodes) {
825
+ var nodeObj = allNodes[id];
826
+ var dummy = {
827
+ layer: nodeObj._layer,
828
+ isDead: nodeObj._isDead,
829
+ hasAntiPattern: nodeObj._hasAntiPattern,
830
+ };
831
+ var colors = resolveNodeColors(dummy, nodeObj._community);
832
+ nodeObj.color.background = colors.fill;
833
+ nodeObj.color.border = colors.border;
834
+ nodeObj.color.highlight = { background: '#ffffff', border: colors.border };
835
+ nodeObj.color.hover = { background: '#ffffff', border: colors.border };
836
+ nodeObj.borderWidth = colors.borderWidth;
837
+ updates.push({ id: id, color: nodeObj.color, borderWidth: colors.borderWidth });
838
+ }
839
+ if (nodesDataset) nodesDataset.update(updates);
840
+ }
841
+
842
+ function applyCommunityFilter() {
843
+ if (!nodesDataset) return;
844
+ // Node is hidden if its community is in hiddenCommunities (only relevant
845
+ // when coloring by community; layer filter stays independent).
846
+ var updates = [];
847
+ for (var id in allNodes) {
848
+ var n = allNodes[id];
849
+ var layerHidden = hiddenLayers.has(n._layer);
850
+ var communityHidden = hiddenCommunities.has(String(n._community));
851
+ var hidden = layerHidden || communityHidden;
852
+ updates.push({ id: id, hidden: hidden });
853
+ }
854
+ nodesDataset.update(updates);
855
+ }
856
+
857
+ function buildCommunityLegend(data) {
858
+ var body = document.getElementById('communityBody');
859
+ var header = document.getElementById('communityHeader');
860
+ if (!body || !header) return;
861
+ while (body.firstChild) body.removeChild(body.firstChild);
862
+
863
+ var communities = (data.communities || []).slice();
864
+ if (communities.length === 0) {
865
+ body.appendChild(el('div', { style: { color: 'var(--text4)', fontSize: '10px', padding: '4px 6px' }, textContent: 'No community snapshot. Run `ctxo index`.' }));
866
+ }
867
+
868
+ // Show top 12 by member count; rest fold into an "other" entry.
869
+ var top = communities.slice(0, 12);
870
+ top.forEach(function(c) {
871
+ var color = communityColor(c.id);
872
+ var label = c.label || ('community-' + c.id);
873
+ var item = el('div', { className: 'chip-item' }, [
874
+ el('span', { className: 'chip-dot', style: { background: color } }),
875
+ el('span', { textContent: label }),
876
+ el('span', { className: 'chip-count', textContent: String(c.memberCount) })
877
+ ]);
878
+ item.onclick = function() {
879
+ var key = String(c.id);
880
+ if (hiddenCommunities.has(key)) {
881
+ hiddenCommunities.delete(key);
882
+ item.classList.remove('dimmed');
883
+ } else {
884
+ hiddenCommunities.add(key);
885
+ item.classList.add('dimmed');
886
+ }
887
+ applyCommunityFilter();
888
+ };
889
+ body.appendChild(item);
890
+ });
891
+ if (communities.length > top.length) {
892
+ body.appendChild(el('div', { style: { color: 'var(--text4)', fontSize: '10px', padding: '4px 6px' }, textContent: '+ ' + (communities.length - top.length) + ' more communities' }));
893
+ }
894
+
895
+ if (typeof data.violationCount === 'number' && data.violationCount > 0) {
896
+ var vRow = el('div', { className: 'chip-item', style: { marginTop: '4px', borderTop: '1px dashed var(--border)', paddingTop: '6px' } }, [
897
+ el('span', { className: 'chip-dot', style: { background: VIOLATION_COLOR_HIGH } }),
898
+ el('span', { textContent: 'Boundary violations' }),
899
+ el('span', { className: 'chip-count', textContent: String(data.violationCount) })
900
+ ]);
901
+ body.appendChild(vRow);
902
+ }
903
+
904
+ header.onclick = function() {
905
+ body.classList.toggle('collapsed');
906
+ header.querySelector('.arrow').classList.toggle('open');
907
+ };
908
+ }
909
+
644
910
  function buildLayerLegend(data) {
645
911
  var body = document.getElementById('layerBody');
646
912
  var header = document.getElementById('layerHeader');