@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.
- package/README.md +25 -262
- package/dist/atomic-write-AQNYJFAG.js +10 -0
- package/dist/{chunk-WXA2JJLH.js → chunk-2TVHKCCU.js} +3 -2
- package/dist/chunk-2TVHKCCU.js.map +1 -0
- package/dist/chunk-6EKERTJ7.js +80 -0
- package/dist/chunk-6EKERTJ7.js.map +1 -0
- package/dist/{chunk-4M4V5V36.js → chunk-HXEPS2XY.js} +466 -78
- package/dist/chunk-HXEPS2XY.js.map +1 -0
- package/dist/{cli-router-T3VZZJD6.js → cli-router-HEZER4G7.js} +892 -201
- package/dist/cli-router-HEZER4G7.js.map +1 -0
- package/dist/index.js +310 -55
- package/dist/index.js.map +1 -1
- package/dist/json-index-reader-XOEE6VMD.js +8 -0
- package/dist/json-index-reader-XOEE6VMD.js.map +1 -0
- package/package.json +4 -2
- package/pages/visualize-template.html +285 -19
- package/dist/chunk-4M4V5V36.js.map +0 -1
- package/dist/chunk-WXA2JJLH.js.map +0 -1
- package/dist/cli-router-T3VZZJD6.js.map +0 -1
- package/dist/json-index-reader-LME4EDA5.js +0 -8
- /package/dist/{json-index-reader-LME4EDA5.js.map → atomic-write-AQNYJFAG.js.map} +0 -0
|
@@ -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.
|
|
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">▼</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">▼</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
|
|
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:
|
|
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:
|
|
287
|
-
border:
|
|
288
|
-
highlight: { background: '#ffffff', border:
|
|
289
|
-
hover: { background: '#ffffff', border:
|
|
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:
|
|
292
|
-
borderWidthSelected: 3,
|
|
293
|
-
font: { color: '#
|
|
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:
|
|
334
|
-
width:
|
|
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
|
-
//
|
|
406
|
-
document.
|
|
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');
|