gitfamiliar 0.5.0 → 0.8.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/README.md +64 -73
- package/dist/bin/gitfamiliar.js +1027 -163
- package/dist/bin/gitfamiliar.js.map +1 -1
- package/dist/{chunk-LZ67KNHF.js → chunk-R5MGQGFI.js} +14 -285
- package/dist/chunk-R5MGQGFI.js.map +1 -0
- package/dist/index.d.ts +3 -12
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-LZ67KNHF.js.map +0 -1
package/dist/bin/gitfamiliar.js
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
GitClient,
|
|
4
|
-
GitHubClient,
|
|
5
4
|
buildFileTree,
|
|
6
5
|
computeFamiliarity,
|
|
7
6
|
createFilter,
|
|
8
7
|
parseExpirationConfig,
|
|
9
8
|
processBatch,
|
|
10
|
-
resolveGitHubToken,
|
|
11
9
|
resolveUser,
|
|
12
10
|
walkFiles
|
|
13
|
-
} from "../chunk-
|
|
11
|
+
} from "../chunk-R5MGQGFI.js";
|
|
14
12
|
|
|
15
13
|
// src/cli/index.ts
|
|
16
14
|
import { Command } from "commander";
|
|
@@ -18,8 +16,7 @@ import { Command } from "commander";
|
|
|
18
16
|
// src/core/types.ts
|
|
19
17
|
var DEFAULT_WEIGHTS = {
|
|
20
18
|
blame: 0.5,
|
|
21
|
-
commit: 0.
|
|
22
|
-
review: 0.15
|
|
19
|
+
commit: 0.5
|
|
23
20
|
};
|
|
24
21
|
var DEFAULT_EXPIRATION = {
|
|
25
22
|
policy: "never"
|
|
@@ -28,7 +25,6 @@ var DEFAULT_EXPIRATION = {
|
|
|
28
25
|
// src/cli/options.ts
|
|
29
26
|
function parseOptions(raw, repoPath) {
|
|
30
27
|
const mode = validateMode(raw.mode || "binary");
|
|
31
|
-
const filter = validateFilter(raw.filter || "all");
|
|
32
28
|
let weights = DEFAULT_WEIGHTS;
|
|
33
29
|
if (raw.weights) {
|
|
34
30
|
weights = parseWeights(raw.weights);
|
|
@@ -52,7 +48,6 @@ function parseOptions(raw, repoPath) {
|
|
|
52
48
|
return {
|
|
53
49
|
mode,
|
|
54
50
|
user,
|
|
55
|
-
filter,
|
|
56
51
|
expiration,
|
|
57
52
|
html: raw.html || false,
|
|
58
53
|
weights,
|
|
@@ -60,18 +55,11 @@ function parseOptions(raw, repoPath) {
|
|
|
60
55
|
team: raw.team || false,
|
|
61
56
|
teamCoverage: raw.teamCoverage || false,
|
|
62
57
|
hotspot,
|
|
63
|
-
window: windowDays
|
|
64
|
-
githubUrl: raw.githubUrl,
|
|
65
|
-
checkGithub: raw.checkGithub || false
|
|
58
|
+
window: windowDays
|
|
66
59
|
};
|
|
67
60
|
}
|
|
68
61
|
function validateMode(mode) {
|
|
69
|
-
const valid = [
|
|
70
|
-
"binary",
|
|
71
|
-
"authorship",
|
|
72
|
-
"review-coverage",
|
|
73
|
-
"weighted"
|
|
74
|
-
];
|
|
62
|
+
const valid = ["binary", "authorship", "weighted"];
|
|
75
63
|
if (!valid.includes(mode)) {
|
|
76
64
|
throw new Error(
|
|
77
65
|
`Invalid mode: "${mode}". Valid modes: ${valid.join(", ")}`
|
|
@@ -79,27 +67,16 @@ function validateMode(mode) {
|
|
|
79
67
|
}
|
|
80
68
|
return mode;
|
|
81
69
|
}
|
|
82
|
-
function validateFilter(filter) {
|
|
83
|
-
const valid = ["all", "written", "reviewed"];
|
|
84
|
-
if (!valid.includes(filter)) {
|
|
85
|
-
throw new Error(
|
|
86
|
-
`Invalid filter: "${filter}". Valid filters: ${valid.join(", ")}`
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
return filter;
|
|
90
|
-
}
|
|
91
70
|
function parseWeights(s) {
|
|
92
71
|
const parts = s.split(",").map(Number);
|
|
93
|
-
if (parts.length !==
|
|
94
|
-
throw new Error(
|
|
95
|
-
`Invalid weights: "${s}". Expected format: "0.5,0.35,0.15"`
|
|
96
|
-
);
|
|
72
|
+
if (parts.length !== 2 || parts.some(isNaN)) {
|
|
73
|
+
throw new Error(`Invalid weights: "${s}". Expected format: "0.5,0.5"`);
|
|
97
74
|
}
|
|
98
|
-
const sum = parts[0] + parts[1]
|
|
75
|
+
const sum = parts[0] + parts[1];
|
|
99
76
|
if (Math.abs(sum - 1) > 0.01) {
|
|
100
77
|
throw new Error(`Weights must sum to 1.0, got ${sum}`);
|
|
101
78
|
}
|
|
102
|
-
return { blame: parts[0], commit: parts[1]
|
|
79
|
+
return { blame: parts[0], commit: parts[1] };
|
|
103
80
|
}
|
|
104
81
|
|
|
105
82
|
// src/cli/output/terminal.ts
|
|
@@ -125,17 +102,17 @@ function getModeLabel(mode) {
|
|
|
125
102
|
return "Binary mode";
|
|
126
103
|
case "authorship":
|
|
127
104
|
return "Authorship mode";
|
|
128
|
-
case "review-coverage":
|
|
129
|
-
return "Review Coverage mode";
|
|
130
105
|
case "weighted":
|
|
131
106
|
return "Weighted mode";
|
|
132
107
|
default:
|
|
133
108
|
return mode;
|
|
134
109
|
}
|
|
135
110
|
}
|
|
111
|
+
var NAME_COLUMN_WIDTH = 24;
|
|
136
112
|
function renderFolder(node, indent, mode, maxDepth) {
|
|
137
113
|
const lines = [];
|
|
138
114
|
const prefix = " ".repeat(indent);
|
|
115
|
+
const prefixWidth = indent * 2;
|
|
139
116
|
const sorted = [...node.children].sort((a, b) => {
|
|
140
117
|
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
|
|
141
118
|
return a.path.localeCompare(b.path);
|
|
@@ -146,14 +123,19 @@ function renderFolder(node, indent, mode, maxDepth) {
|
|
|
146
123
|
const name = folder.path.split("/").pop() + "/";
|
|
147
124
|
const bar = makeBar(folder.score);
|
|
148
125
|
const pct = formatPercent(folder.score);
|
|
126
|
+
const padWidth = Math.max(
|
|
127
|
+
1,
|
|
128
|
+
NAME_COLUMN_WIDTH - prefixWidth - name.length
|
|
129
|
+
);
|
|
130
|
+
const padding = " ".repeat(padWidth);
|
|
149
131
|
if (mode === "binary") {
|
|
150
132
|
const readCount = folder.readCount || 0;
|
|
151
133
|
lines.push(
|
|
152
|
-
`${prefix}${chalk.bold(name
|
|
134
|
+
`${prefix}${chalk.bold(name)}${padding} ${bar} ${pct.padStart(4)} (${readCount}/${folder.fileCount} files)`
|
|
153
135
|
);
|
|
154
136
|
} else {
|
|
155
137
|
lines.push(
|
|
156
|
-
`${prefix}${chalk.bold(name
|
|
138
|
+
`${prefix}${chalk.bold(name)}${padding} ${bar} ${pct.padStart(4)}`
|
|
157
139
|
);
|
|
158
140
|
}
|
|
159
141
|
if (indent < maxDepth) {
|
|
@@ -166,7 +148,9 @@ function renderFolder(node, indent, mode, maxDepth) {
|
|
|
166
148
|
function renderTerminal(result) {
|
|
167
149
|
const { tree, repoName, mode } = result;
|
|
168
150
|
console.log("");
|
|
169
|
-
console.log(
|
|
151
|
+
console.log(
|
|
152
|
+
chalk.bold(`GitFamiliar \u2014 ${repoName} (${getModeLabel(mode)})`)
|
|
153
|
+
);
|
|
170
154
|
console.log("");
|
|
171
155
|
if (mode === "binary") {
|
|
172
156
|
const readCount = tree.readCount || 0;
|
|
@@ -183,10 +167,8 @@ function renderTerminal(result) {
|
|
|
183
167
|
}
|
|
184
168
|
console.log("");
|
|
185
169
|
if (mode === "binary") {
|
|
186
|
-
const { writtenCount
|
|
187
|
-
console.log(
|
|
188
|
-
`Written: ${writtenCount} files | Reviewed: ${reviewedCount} files | Both: ${bothCount} files`
|
|
189
|
-
);
|
|
170
|
+
const { writtenCount } = result;
|
|
171
|
+
console.log(`Written: ${writtenCount} files`);
|
|
190
172
|
console.log("");
|
|
191
173
|
}
|
|
192
174
|
}
|
|
@@ -244,28 +226,7 @@ function generateTreemapHTML(result) {
|
|
|
244
226
|
#breadcrumb span { cursor: pointer; color: #5eadf7; }
|
|
245
227
|
#breadcrumb span:hover { text-decoration: underline; }
|
|
246
228
|
#breadcrumb .sep { color: #666; margin: 0 4px; }
|
|
247
|
-
|
|
248
|
-
padding: 8px 24px;
|
|
249
|
-
background: #16213e;
|
|
250
|
-
border-bottom: 1px solid #0f3460;
|
|
251
|
-
display: flex;
|
|
252
|
-
gap: 12px;
|
|
253
|
-
align-items: center;
|
|
254
|
-
}
|
|
255
|
-
#controls button {
|
|
256
|
-
padding: 4px 12px;
|
|
257
|
-
border: 1px solid #0f3460;
|
|
258
|
-
background: #1a1a2e;
|
|
259
|
-
color: #e0e0e0;
|
|
260
|
-
border-radius: 4px;
|
|
261
|
-
cursor: pointer;
|
|
262
|
-
font-size: 12px;
|
|
263
|
-
}
|
|
264
|
-
#controls button.active {
|
|
265
|
-
background: #e94560;
|
|
266
|
-
border-color: #e94560;
|
|
267
|
-
color: white;
|
|
268
|
-
}
|
|
229
|
+
|
|
269
230
|
#treemap { width: 100%; }
|
|
270
231
|
#tooltip {
|
|
271
232
|
position: absolute;
|
|
@@ -306,13 +267,7 @@ function generateTreemapHTML(result) {
|
|
|
306
267
|
<div class="info">${mode.charAt(0).toUpperCase() + mode.slice(1)} mode | ${result.totalFiles} files</div>
|
|
307
268
|
</div>
|
|
308
269
|
<div id="breadcrumb"><span onclick="zoomTo('')">root</span></div>
|
|
309
|
-
|
|
310
|
-
<div id="controls">
|
|
311
|
-
<span style="font-size:12px;color:#888;">Filter:</span>
|
|
312
|
-
<button class="active" onclick="setFilter('all')">All</button>
|
|
313
|
-
<button onclick="setFilter('written')">Written only</button>
|
|
314
|
-
<button onclick="setFilter('reviewed')">Reviewed only</button>
|
|
315
|
-
</div>` : ""}
|
|
270
|
+
|
|
316
271
|
<div id="treemap"></div>
|
|
317
272
|
<div id="tooltip"></div>
|
|
318
273
|
<div id="legend">
|
|
@@ -325,7 +280,6 @@ ${mode === "binary" ? `
|
|
|
325
280
|
<script>
|
|
326
281
|
const rawData = ${dataJson};
|
|
327
282
|
const mode = "${mode}";
|
|
328
|
-
let currentFilter = 'all';
|
|
329
283
|
let currentPath = '';
|
|
330
284
|
|
|
331
285
|
function scoreColor(score) {
|
|
@@ -340,9 +294,6 @@ function scoreColor(score) {
|
|
|
340
294
|
}
|
|
341
295
|
|
|
342
296
|
function getNodeScore(node) {
|
|
343
|
-
if (mode !== 'binary') return node.score;
|
|
344
|
-
if (currentFilter === 'written') return node.isWritten ? 1 : 0;
|
|
345
|
-
if (currentFilter === 'reviewed') return node.isReviewed ? 1 : 0;
|
|
346
297
|
return node.score;
|
|
347
298
|
}
|
|
348
299
|
|
|
@@ -382,10 +333,8 @@ function render() {
|
|
|
382
333
|
|
|
383
334
|
const headerH = document.getElementById('header').offsetHeight;
|
|
384
335
|
const breadcrumbH = document.getElementById('breadcrumb').offsetHeight;
|
|
385
|
-
const controlsEl = document.getElementById('controls');
|
|
386
|
-
const controlsH = controlsEl ? controlsEl.offsetHeight : 0;
|
|
387
336
|
const width = window.innerWidth;
|
|
388
|
-
const height = window.innerHeight - headerH - breadcrumbH
|
|
337
|
+
const height = window.innerHeight - headerH - breadcrumbH;
|
|
389
338
|
|
|
390
339
|
const targetNode = currentPath ? findNode(rawData, currentPath) : rawData;
|
|
391
340
|
if (!targetNode) return;
|
|
@@ -492,9 +441,7 @@ function showTooltip(data, event) {
|
|
|
492
441
|
if (data.commitScore !== undefined) {
|
|
493
442
|
html += '<br>Commit: ' + Math.round(data.commitScore * 100) + '%';
|
|
494
443
|
}
|
|
495
|
-
|
|
496
|
-
html += '<br>Review: ' + Math.round(data.reviewScore * 100) + '%';
|
|
497
|
-
}
|
|
444
|
+
|
|
498
445
|
if (data.isExpired) {
|
|
499
446
|
html += '<br><span style="color:#e94560">Expired</span>';
|
|
500
447
|
}
|
|
@@ -523,14 +470,6 @@ function updateBreadcrumb() {
|
|
|
523
470
|
el.innerHTML = html;
|
|
524
471
|
}
|
|
525
472
|
|
|
526
|
-
function setFilter(f) {
|
|
527
|
-
currentFilter = f;
|
|
528
|
-
document.querySelectorAll('#controls button').forEach(btn => {
|
|
529
|
-
btn.classList.toggle('active', btn.textContent.toLowerCase().includes(f));
|
|
530
|
-
});
|
|
531
|
-
render();
|
|
532
|
-
}
|
|
533
|
-
|
|
534
473
|
window.addEventListener('resize', render);
|
|
535
474
|
render();
|
|
536
475
|
</script>
|
|
@@ -1162,7 +1101,6 @@ async function computeMultiUser(options) {
|
|
|
1162
1101
|
const userSummaries = results.map((r) => ({
|
|
1163
1102
|
user: { name: r.result.userName, email: "" },
|
|
1164
1103
|
writtenCount: r.result.writtenCount,
|
|
1165
|
-
reviewedCount: r.result.reviewedCount,
|
|
1166
1104
|
overallScore: r.result.tree.score
|
|
1167
1105
|
}));
|
|
1168
1106
|
return {
|
|
@@ -1199,8 +1137,7 @@ function mergeResults(results) {
|
|
|
1199
1137
|
scores.push({
|
|
1200
1138
|
user: { name: userName, email: "" },
|
|
1201
1139
|
score: file.score,
|
|
1202
|
-
isWritten: file.isWritten
|
|
1203
|
-
isReviewed: file.isReviewed
|
|
1140
|
+
isWritten: file.isWritten
|
|
1204
1141
|
});
|
|
1205
1142
|
});
|
|
1206
1143
|
}
|
|
@@ -1275,8 +1212,6 @@ function getModeLabel2(mode) {
|
|
|
1275
1212
|
return "Binary mode";
|
|
1276
1213
|
case "authorship":
|
|
1277
1214
|
return "Authorship mode";
|
|
1278
|
-
case "review-coverage":
|
|
1279
|
-
return "Review Coverage mode";
|
|
1280
1215
|
case "weighted":
|
|
1281
1216
|
return "Weighted mode";
|
|
1282
1217
|
default:
|
|
@@ -1323,7 +1258,7 @@ function renderMultiUserTerminal(result) {
|
|
|
1323
1258
|
const pct = formatPercent2(summary.overallScore);
|
|
1324
1259
|
if (mode === "binary") {
|
|
1325
1260
|
console.log(
|
|
1326
|
-
` ${name} ${bar} ${pct.padStart(4)} (${summary.writtenCount
|
|
1261
|
+
` ${name} ${bar} ${pct.padStart(4)} (${summary.writtenCount}/${totalFiles} files)`
|
|
1327
1262
|
);
|
|
1328
1263
|
} else {
|
|
1329
1264
|
console.log(` ${name} ${bar} ${pct.padStart(4)}`);
|
|
@@ -1332,9 +1267,7 @@ function renderMultiUserTerminal(result) {
|
|
|
1332
1267
|
console.log("");
|
|
1333
1268
|
const nameWidth = 20;
|
|
1334
1269
|
const headerNames = userSummaries.map((s) => truncateName(s.user.name, 7).padStart(7)).join(" ");
|
|
1335
|
-
console.log(
|
|
1336
|
-
chalk3.bold("Folders:") + " ".repeat(nameWidth - 4) + headerNames
|
|
1337
|
-
);
|
|
1270
|
+
console.log(chalk3.bold("Folders:") + " ".repeat(nameWidth - 4) + headerNames);
|
|
1338
1271
|
const folderLines = renderFolder3(tree, 1, 2, nameWidth);
|
|
1339
1272
|
for (const line of folderLines) {
|
|
1340
1273
|
console.log(line);
|
|
@@ -2149,84 +2082,1020 @@ async function generateAndOpenHotspotHTML(result, repoPath) {
|
|
|
2149
2082
|
await openBrowser(outputPath);
|
|
2150
2083
|
}
|
|
2151
2084
|
|
|
2152
|
-
// src/
|
|
2153
|
-
async function
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
console.log(
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2085
|
+
// src/core/unified.ts
|
|
2086
|
+
async function computeUnified(options) {
|
|
2087
|
+
console.log("Computing unified dashboard data...");
|
|
2088
|
+
console.log(" [1/4] Scoring (binary, authorship, weighted)...");
|
|
2089
|
+
const [binary, authorship, weighted] = await Promise.all([
|
|
2090
|
+
computeFamiliarity({ ...options, mode: "binary" }),
|
|
2091
|
+
computeFamiliarity({ ...options, mode: "authorship" }),
|
|
2092
|
+
computeFamiliarity({ ...options, mode: "weighted" })
|
|
2093
|
+
]);
|
|
2094
|
+
console.log(" [2/4] Team coverage...");
|
|
2095
|
+
const coverage = await computeTeamCoverage(options);
|
|
2096
|
+
console.log(" [3/4] Hotspot analysis...");
|
|
2097
|
+
const hotspot = await computeHotspots({
|
|
2098
|
+
...options,
|
|
2099
|
+
hotspot: "personal"
|
|
2100
|
+
});
|
|
2101
|
+
console.log(" [4/4] Multi-user comparison...");
|
|
2102
|
+
const multiUser = await computeMultiUser({
|
|
2103
|
+
...options,
|
|
2104
|
+
team: true
|
|
2105
|
+
});
|
|
2106
|
+
console.log("Done.");
|
|
2107
|
+
return {
|
|
2108
|
+
repoName: binary.repoName,
|
|
2109
|
+
userName: binary.userName,
|
|
2110
|
+
scoring: { binary, authorship, weighted },
|
|
2111
|
+
coverage,
|
|
2112
|
+
hotspot,
|
|
2113
|
+
multiUser
|
|
2114
|
+
};
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
// src/cli/output/unified-html.ts
|
|
2118
|
+
import { writeFileSync as writeFileSync5 } from "fs";
|
|
2119
|
+
import { join as join5 } from "path";
|
|
2120
|
+
function generateUnifiedHTML(data) {
|
|
2121
|
+
const scoringBinaryJson = JSON.stringify(data.scoring.binary.tree);
|
|
2122
|
+
const scoringAuthorshipJson = JSON.stringify(data.scoring.authorship.tree);
|
|
2123
|
+
const scoringWeightedJson = JSON.stringify(data.scoring.weighted.tree);
|
|
2124
|
+
const coverageTreeJson = JSON.stringify(data.coverage.tree);
|
|
2125
|
+
const coverageRiskJson = JSON.stringify(data.coverage.riskFiles);
|
|
2126
|
+
const hotspotJson = JSON.stringify(
|
|
2127
|
+
data.hotspot.files.filter((f) => f.changeFrequency > 0).map((f) => ({
|
|
2128
|
+
path: f.path,
|
|
2129
|
+
lines: f.lines,
|
|
2130
|
+
familiarity: f.familiarity,
|
|
2131
|
+
changeFrequency: f.changeFrequency,
|
|
2132
|
+
risk: f.risk,
|
|
2133
|
+
riskLevel: f.riskLevel
|
|
2134
|
+
}))
|
|
2135
|
+
);
|
|
2136
|
+
const multiUserTreeJson = JSON.stringify(data.multiUser.tree);
|
|
2137
|
+
const multiUserSummariesJson = JSON.stringify(data.multiUser.userSummaries);
|
|
2138
|
+
const multiUserNamesJson = JSON.stringify(
|
|
2139
|
+
data.multiUser.users.map((u) => u.name)
|
|
2140
|
+
);
|
|
2141
|
+
return `<!DOCTYPE html>
|
|
2142
|
+
<html lang="en">
|
|
2143
|
+
<head>
|
|
2144
|
+
<meta charset="UTF-8">
|
|
2145
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2146
|
+
<title>GitFamiliar \u2014 ${data.repoName}</title>
|
|
2147
|
+
<style>
|
|
2148
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
2149
|
+
body {
|
|
2150
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
2151
|
+
background: #1a1a2e;
|
|
2152
|
+
color: #e0e0e0;
|
|
2153
|
+
overflow: hidden;
|
|
2154
|
+
display: flex;
|
|
2155
|
+
flex-direction: column;
|
|
2156
|
+
height: 100vh;
|
|
2187
2157
|
}
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2158
|
+
#header {
|
|
2159
|
+
padding: 12px 24px;
|
|
2160
|
+
background: #16213e;
|
|
2161
|
+
border-bottom: 1px solid #0f3460;
|
|
2162
|
+
display: flex;
|
|
2163
|
+
align-items: center;
|
|
2164
|
+
justify-content: space-between;
|
|
2165
|
+
}
|
|
2166
|
+
#header h1 { font-size: 18px; color: #e94560; }
|
|
2167
|
+
#header .info { font-size: 13px; color: #a0a0a0; }
|
|
2168
|
+
|
|
2169
|
+
/* Tabs */
|
|
2170
|
+
#tabs {
|
|
2171
|
+
display: flex;
|
|
2172
|
+
background: #16213e;
|
|
2173
|
+
border-bottom: 1px solid #0f3460;
|
|
2174
|
+
padding: 0 24px;
|
|
2175
|
+
}
|
|
2176
|
+
#tabs .tab {
|
|
2177
|
+
padding: 10px 20px;
|
|
2178
|
+
cursor: pointer;
|
|
2179
|
+
color: #888;
|
|
2180
|
+
border-bottom: 2px solid transparent;
|
|
2181
|
+
font-size: 14px;
|
|
2182
|
+
transition: color 0.15s;
|
|
2183
|
+
}
|
|
2184
|
+
#tabs .tab:hover { color: #ccc; }
|
|
2185
|
+
#tabs .tab.active { color: #e94560; border-bottom-color: #e94560; }
|
|
2186
|
+
|
|
2187
|
+
/* Sub-tabs (scoring modes) */
|
|
2188
|
+
#scoring-controls {
|
|
2189
|
+
display: none;
|
|
2190
|
+
padding: 8px 24px;
|
|
2191
|
+
background: #16213e;
|
|
2192
|
+
border-bottom: 1px solid #0f3460;
|
|
2193
|
+
align-items: center;
|
|
2194
|
+
gap: 16px;
|
|
2195
|
+
}
|
|
2196
|
+
#scoring-controls.visible { display: flex; }
|
|
2197
|
+
.subtab {
|
|
2198
|
+
padding: 5px 14px;
|
|
2199
|
+
cursor: pointer;
|
|
2200
|
+
color: #888;
|
|
2201
|
+
border: 1px solid #0f3460;
|
|
2202
|
+
border-radius: 4px;
|
|
2203
|
+
font-size: 12px;
|
|
2204
|
+
background: transparent;
|
|
2205
|
+
transition: all 0.15s;
|
|
2206
|
+
}
|
|
2207
|
+
.subtab:hover { color: #ccc; border-color: #555; }
|
|
2208
|
+
.subtab.active { color: #e94560; border-color: #e94560; background: rgba(233,69,96,0.1); }
|
|
2209
|
+
#weight-controls {
|
|
2210
|
+
display: none;
|
|
2211
|
+
align-items: center;
|
|
2212
|
+
gap: 8px;
|
|
2213
|
+
margin-left: 24px;
|
|
2214
|
+
font-size: 12px;
|
|
2215
|
+
color: #a0a0a0;
|
|
2216
|
+
}
|
|
2217
|
+
#weight-controls.visible { display: flex; }
|
|
2218
|
+
#weight-controls input[type="range"] {
|
|
2219
|
+
width: 120px;
|
|
2220
|
+
accent-color: #e94560;
|
|
2221
|
+
}
|
|
2222
|
+
#weight-controls .weight-label { min-width: 36px; text-align: right; color: #e0e0e0; }
|
|
2223
|
+
|
|
2224
|
+
/* Breadcrumb */
|
|
2225
|
+
#breadcrumb {
|
|
2226
|
+
padding: 8px 24px;
|
|
2227
|
+
background: #16213e;
|
|
2228
|
+
font-size: 13px;
|
|
2229
|
+
border-bottom: 1px solid #0f3460;
|
|
2230
|
+
display: none;
|
|
2231
|
+
}
|
|
2232
|
+
#breadcrumb.visible { display: block; }
|
|
2233
|
+
#breadcrumb span { cursor: pointer; color: #5eadf7; }
|
|
2234
|
+
#breadcrumb span:hover { text-decoration: underline; }
|
|
2235
|
+
#breadcrumb .sep { color: #666; margin: 0 4px; }
|
|
2236
|
+
|
|
2237
|
+
/* Tab descriptions */
|
|
2238
|
+
.tab-desc {
|
|
2239
|
+
padding: 8px 24px;
|
|
2240
|
+
background: #16213e;
|
|
2241
|
+
border-bottom: 1px solid #0f3460;
|
|
2242
|
+
font-size: 12px;
|
|
2243
|
+
color: #888;
|
|
2244
|
+
display: none;
|
|
2245
|
+
}
|
|
2246
|
+
.tab-desc.visible { display: block; }
|
|
2247
|
+
|
|
2248
|
+
/* Tab content */
|
|
2249
|
+
#content-area { flex: 1; position: relative; overflow: hidden; }
|
|
2250
|
+
.tab-content { display: none; position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
|
|
2251
|
+
.tab-content.active { display: block; }
|
|
2252
|
+
.tab-content.with-sidebar.active { display: flex; }
|
|
2253
|
+
|
|
2254
|
+
/* Layout with sidebar */
|
|
2255
|
+
.with-sidebar .viz-area { flex: 1; position: relative; height: 100%; }
|
|
2256
|
+
.with-sidebar .sidebar {
|
|
2257
|
+
width: 300px;
|
|
2258
|
+
height: 100%;
|
|
2259
|
+
background: #16213e;
|
|
2260
|
+
border-left: 1px solid #0f3460;
|
|
2261
|
+
overflow-y: auto;
|
|
2262
|
+
padding: 16px;
|
|
2263
|
+
}
|
|
2264
|
+
.sidebar h3 { font-size: 14px; margin-bottom: 12px; color: #e94560; }
|
|
2265
|
+
.sidebar .risk-file, .sidebar .hotspot-item {
|
|
2266
|
+
padding: 6px 0;
|
|
2267
|
+
border-bottom: 1px solid #0f3460;
|
|
2268
|
+
font-size: 12px;
|
|
2269
|
+
}
|
|
2270
|
+
.sidebar .path { color: #e0e0e0; word-break: break-all; }
|
|
2271
|
+
.sidebar .meta { color: #888; margin-top: 2px; }
|
|
2272
|
+
.risk-badge {
|
|
2273
|
+
display: inline-block;
|
|
2274
|
+
padding: 1px 6px;
|
|
2275
|
+
border-radius: 3px;
|
|
2276
|
+
font-size: 10px;
|
|
2277
|
+
font-weight: bold;
|
|
2278
|
+
margin-left: 4px;
|
|
2279
|
+
}
|
|
2280
|
+
.risk-critical { background: #e94560; color: white; }
|
|
2281
|
+
.risk-high { background: #f07040; color: white; }
|
|
2282
|
+
.risk-medium { background: #f5a623; color: black; }
|
|
2283
|
+
.risk-low { background: #27ae60; color: white; }
|
|
2284
|
+
|
|
2285
|
+
/* Multi-user controls */
|
|
2286
|
+
#multiuser-controls {
|
|
2287
|
+
display: none;
|
|
2288
|
+
padding: 8px 24px;
|
|
2289
|
+
background: #16213e;
|
|
2290
|
+
border-bottom: 1px solid #0f3460;
|
|
2291
|
+
align-items: center;
|
|
2292
|
+
gap: 12px;
|
|
2293
|
+
}
|
|
2294
|
+
#multiuser-controls.visible { display: flex; }
|
|
2295
|
+
#multiuser-controls select {
|
|
2296
|
+
padding: 4px 12px;
|
|
2297
|
+
border: 1px solid #0f3460;
|
|
2298
|
+
background: #1a1a2e;
|
|
2299
|
+
color: #e0e0e0;
|
|
2300
|
+
border-radius: 4px;
|
|
2301
|
+
font-size: 13px;
|
|
2302
|
+
}
|
|
2303
|
+
#multiuser-controls label { font-size: 13px; color: #888; }
|
|
2304
|
+
|
|
2305
|
+
/* Tooltip */
|
|
2306
|
+
#tooltip {
|
|
2307
|
+
position: absolute;
|
|
2308
|
+
pointer-events: none;
|
|
2309
|
+
background: rgba(22, 33, 62, 0.95);
|
|
2310
|
+
border: 1px solid #0f3460;
|
|
2311
|
+
border-radius: 6px;
|
|
2312
|
+
padding: 10px 14px;
|
|
2313
|
+
font-size: 13px;
|
|
2314
|
+
line-height: 1.6;
|
|
2315
|
+
display: none;
|
|
2316
|
+
z-index: 100;
|
|
2317
|
+
max-width: 350px;
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
/* Legends */
|
|
2321
|
+
.legend {
|
|
2322
|
+
position: absolute;
|
|
2323
|
+
bottom: 16px;
|
|
2324
|
+
right: 16px;
|
|
2325
|
+
background: rgba(22, 33, 62, 0.9);
|
|
2326
|
+
border: 1px solid #0f3460;
|
|
2327
|
+
border-radius: 6px;
|
|
2328
|
+
padding: 10px;
|
|
2329
|
+
font-size: 12px;
|
|
2330
|
+
display: none;
|
|
2331
|
+
z-index: 50;
|
|
2332
|
+
}
|
|
2333
|
+
.legend.active { display: block; }
|
|
2334
|
+
.legend .gradient-bar {
|
|
2335
|
+
width: 120px;
|
|
2336
|
+
height: 12px;
|
|
2337
|
+
background: linear-gradient(to right, #e94560, #f5a623, #27ae60);
|
|
2338
|
+
border-radius: 3px;
|
|
2339
|
+
margin: 4px 0;
|
|
2201
2340
|
}
|
|
2341
|
+
.legend .labels { display: flex; justify-content: space-between; font-size: 10px; color: #888; }
|
|
2342
|
+
.legend .row { display: flex; align-items: center; gap: 6px; margin: 3px 0; }
|
|
2343
|
+
.legend .swatch { width: 14px; height: 14px; border-radius: 3px; }
|
|
2344
|
+
|
|
2345
|
+
/* Zone labels for hotspot */
|
|
2346
|
+
#zone-labels { position: absolute; pointer-events: none; }
|
|
2347
|
+
.zone-label {
|
|
2348
|
+
position: absolute;
|
|
2349
|
+
font-size: 16px;
|
|
2350
|
+
font-weight: bold;
|
|
2351
|
+
}
|
|
2352
|
+
</style>
|
|
2353
|
+
</head>
|
|
2354
|
+
<body>
|
|
2355
|
+
<div id="header">
|
|
2356
|
+
<h1>GitFamiliar \u2014 ${data.repoName}</h1>
|
|
2357
|
+
<div class="info">${data.userName} | ${data.scoring.binary.totalFiles} files</div>
|
|
2358
|
+
</div>
|
|
2359
|
+
|
|
2360
|
+
<div id="tabs">
|
|
2361
|
+
<div class="tab active" onclick="switchTab('scoring')">Scoring</div>
|
|
2362
|
+
<div class="tab" onclick="switchTab('coverage')">Coverage</div>
|
|
2363
|
+
<div class="tab" onclick="switchTab('multiuser')">Multi-User</div>
|
|
2364
|
+
<div class="tab" onclick="switchTab('hotspots')">Hotspots</div>
|
|
2365
|
+
</div>
|
|
2366
|
+
|
|
2367
|
+
<div id="tab-desc-scoring" class="tab-desc visible">
|
|
2368
|
+
Your personal familiarity with each file, based on Git history. Larger blocks = more lines of code. Color shows how well you know each file.
|
|
2369
|
+
</div>
|
|
2370
|
+
<div id="tab-desc-coverage" class="tab-desc">
|
|
2371
|
+
Team knowledge distribution: how many people have contributed to each file. Low contributor count = high bus factor risk.
|
|
2372
|
+
</div>
|
|
2373
|
+
<div id="tab-desc-multiuser" class="tab-desc">
|
|
2374
|
+
Compare familiarity scores across team members. Select a user to see the codebase colored by their knowledge.
|
|
2375
|
+
</div>
|
|
2376
|
+
<div id="tab-desc-hotspots" class="tab-desc">
|
|
2377
|
+
Files that change frequently but are poorly understood. Top-left = danger zone (high change, low familiarity).
|
|
2378
|
+
</div>
|
|
2379
|
+
|
|
2380
|
+
<div id="scoring-controls" class="visible">
|
|
2381
|
+
<button class="subtab active" onclick="switchScoringMode('binary')">Binary</button>
|
|
2382
|
+
<button class="subtab" onclick="switchScoringMode('authorship')">Authorship</button>
|
|
2383
|
+
<button class="subtab" onclick="switchScoringMode('weighted')">Weighted</button>
|
|
2384
|
+
<div id="weight-controls">
|
|
2385
|
+
<span>Blame:</span>
|
|
2386
|
+
<span class="weight-label" id="blame-label">50%</span>
|
|
2387
|
+
<input type="range" id="blame-slider" min="0" max="100" value="50" oninput="onWeightChange()">
|
|
2388
|
+
<span>Commit:</span>
|
|
2389
|
+
<span class="weight-label" id="commit-label">50%</span>
|
|
2390
|
+
</div>
|
|
2391
|
+
</div>
|
|
2392
|
+
<div id="scoring-mode-desc" class="tab-desc visible" style="padding-top:0">
|
|
2393
|
+
<span id="mode-desc-text">Binary: Have you ever committed to this file? Yes (green) or No (red).</span>
|
|
2394
|
+
</div>
|
|
2395
|
+
|
|
2396
|
+
<div id="multiuser-controls">
|
|
2397
|
+
<label>View as:</label>
|
|
2398
|
+
<select id="userSelect" onchange="onUserChange()"></select>
|
|
2399
|
+
</div>
|
|
2400
|
+
|
|
2401
|
+
<div id="breadcrumb"><span onclick="zoomTo('')">root</span></div>
|
|
2402
|
+
|
|
2403
|
+
<div id="content-area">
|
|
2404
|
+
<div id="tab-scoring" class="tab-content active"></div>
|
|
2405
|
+
<div id="tab-coverage" class="tab-content with-sidebar">
|
|
2406
|
+
<div class="viz-area" id="coverage-viz"></div>
|
|
2407
|
+
<div class="sidebar" id="coverage-sidebar">
|
|
2408
|
+
<h3>Risk Files (0-1 contributors)</h3>
|
|
2409
|
+
<div id="risk-list"></div>
|
|
2410
|
+
</div>
|
|
2411
|
+
</div>
|
|
2412
|
+
<div id="tab-multiuser" class="tab-content"></div>
|
|
2413
|
+
<div id="tab-hotspots" class="tab-content with-sidebar">
|
|
2414
|
+
<div class="viz-area" id="hotspot-viz">
|
|
2415
|
+
<div id="zone-labels"></div>
|
|
2416
|
+
</div>
|
|
2417
|
+
<div class="sidebar" id="hotspot-sidebar">
|
|
2418
|
+
<h3>Top Hotspots</h3>
|
|
2419
|
+
<div id="hotspot-list"></div>
|
|
2420
|
+
</div>
|
|
2421
|
+
</div>
|
|
2422
|
+
</div>
|
|
2423
|
+
|
|
2424
|
+
<div id="tooltip"></div>
|
|
2425
|
+
|
|
2426
|
+
<!-- Legends -->
|
|
2427
|
+
<div class="legend active" id="legend-scoring">
|
|
2428
|
+
<div>Familiarity</div>
|
|
2429
|
+
<div class="gradient-bar"></div>
|
|
2430
|
+
<div class="labels"><span>0%</span><span>50%</span><span>100%</span></div>
|
|
2431
|
+
</div>
|
|
2432
|
+
<div class="legend" id="legend-coverage">
|
|
2433
|
+
<div>Contributors</div>
|
|
2434
|
+
<div class="row"><div class="swatch" style="background:#e94560"></div> 0\u20131 (Risk)</div>
|
|
2435
|
+
<div class="row"><div class="swatch" style="background:#f5a623"></div> 2\u20133 (Moderate)</div>
|
|
2436
|
+
<div class="row"><div class="swatch" style="background:#27ae60"></div> 4+ (Safe)</div>
|
|
2437
|
+
</div>
|
|
2438
|
+
<div class="legend" id="legend-multiuser">
|
|
2439
|
+
<div>Familiarity</div>
|
|
2440
|
+
<div class="gradient-bar"></div>
|
|
2441
|
+
<div class="labels"><span>0%</span><span>50%</span><span>100%</span></div>
|
|
2442
|
+
</div>
|
|
2443
|
+
|
|
2444
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
2445
|
+
<script>
|
|
2446
|
+
// \u2500\u2500 Data \u2500\u2500
|
|
2447
|
+
const scoringData = {
|
|
2448
|
+
binary: ${scoringBinaryJson},
|
|
2449
|
+
authorship: ${scoringAuthorshipJson},
|
|
2450
|
+
weighted: ${scoringWeightedJson},
|
|
2451
|
+
};
|
|
2452
|
+
const coverageData = ${coverageTreeJson};
|
|
2453
|
+
const coverageRiskFiles = ${coverageRiskJson};
|
|
2454
|
+
const hotspotData = ${hotspotJson};
|
|
2455
|
+
const multiUserData = ${multiUserTreeJson};
|
|
2456
|
+
const multiUserNames = ${multiUserNamesJson};
|
|
2457
|
+
const multiUserSummaries = ${multiUserSummariesJson};
|
|
2458
|
+
|
|
2459
|
+
// \u2500\u2500 State \u2500\u2500
|
|
2460
|
+
let activeTab = 'scoring';
|
|
2461
|
+
let scoringMode = 'binary';
|
|
2462
|
+
let blameWeight = 0.5;
|
|
2463
|
+
let scoringPath = '';
|
|
2464
|
+
let coveragePath = '';
|
|
2465
|
+
let multiuserPath = '';
|
|
2466
|
+
let currentUser = 0;
|
|
2467
|
+
const rendered = { scoring: false, coverage: false, hotspots: false, multiuser: false };
|
|
2468
|
+
|
|
2469
|
+
// \u2500\u2500 Common utilities \u2500\u2500
|
|
2470
|
+
function scoreColor(score) {
|
|
2471
|
+
if (score <= 0) return '#e94560';
|
|
2472
|
+
if (score >= 1) return '#27ae60';
|
|
2473
|
+
if (score < 0.5) return d3.interpolateRgb('#e94560', '#f5a623')(score / 0.5);
|
|
2474
|
+
return d3.interpolateRgb('#f5a623', '#27ae60')((score - 0.5) / 0.5);
|
|
2202
2475
|
}
|
|
2203
2476
|
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2477
|
+
function coverageColor(count) {
|
|
2478
|
+
if (count <= 0) return '#e94560';
|
|
2479
|
+
if (count === 1) return '#d63c57';
|
|
2480
|
+
if (count <= 3) return '#f5a623';
|
|
2481
|
+
return '#27ae60';
|
|
2207
2482
|
}
|
|
2208
|
-
|
|
2483
|
+
|
|
2484
|
+
function folderRiskColor(riskLevel) {
|
|
2485
|
+
switch (riskLevel) {
|
|
2486
|
+
case 'risk': return '#e94560';
|
|
2487
|
+
case 'moderate': return '#f5a623';
|
|
2488
|
+
default: return '#27ae60';
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
function riskLevelColor(level) {
|
|
2493
|
+
switch(level) {
|
|
2494
|
+
case 'critical': return '#e94560';
|
|
2495
|
+
case 'high': return '#f07040';
|
|
2496
|
+
case 'medium': return '#f5a623';
|
|
2497
|
+
default: return '#27ae60';
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
function findNode(node, path) {
|
|
2502
|
+
if (node.path === path) return node;
|
|
2503
|
+
if (node.children) {
|
|
2504
|
+
for (const child of node.children) {
|
|
2505
|
+
const found = findNode(child, path);
|
|
2506
|
+
if (found) return found;
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
return null;
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
function buildHierarchy(node) {
|
|
2513
|
+
if (node.type === 'file') {
|
|
2514
|
+
return { name: node.path.split('/').pop(), data: node, value: Math.max(1, node.lines) };
|
|
2515
|
+
}
|
|
2516
|
+
return {
|
|
2517
|
+
name: node.path.split('/').pop() || node.path,
|
|
2518
|
+
data: node,
|
|
2519
|
+
children: (node.children || []).map(c => buildHierarchy(c)),
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
function updateBreadcrumb(path) {
|
|
2524
|
+
const el = document.getElementById('breadcrumb');
|
|
2525
|
+
const parts = path ? path.split('/') : [];
|
|
2526
|
+
let html = '<span onclick="zoomTo(\\'\\')">root</span>';
|
|
2527
|
+
let accumulated = '';
|
|
2528
|
+
for (const part of parts) {
|
|
2529
|
+
accumulated = accumulated ? accumulated + '/' + part : part;
|
|
2530
|
+
const p = accumulated;
|
|
2531
|
+
html += '<span class="sep">/</span><span onclick="zoomTo(\\'' + p + '\\')">' + part + '</span>';
|
|
2532
|
+
}
|
|
2533
|
+
el.innerHTML = html;
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
function showTooltipAt(html, event) {
|
|
2537
|
+
const tooltip = document.getElementById('tooltip');
|
|
2538
|
+
tooltip.innerHTML = html;
|
|
2539
|
+
tooltip.style.display = 'block';
|
|
2540
|
+
tooltip.style.left = (event.pageX + 14) + 'px';
|
|
2541
|
+
tooltip.style.top = (event.pageY - 14) + 'px';
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
function moveTooltip(event) {
|
|
2545
|
+
const tooltip = document.getElementById('tooltip');
|
|
2546
|
+
tooltip.style.left = (event.pageX + 14) + 'px';
|
|
2547
|
+
tooltip.style.top = (event.pageY - 14) + 'px';
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
function hideTooltip() {
|
|
2551
|
+
document.getElementById('tooltip').style.display = 'none';
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
function truncateLabel(name, w, h) {
|
|
2555
|
+
if (w < 36 || h < 18) return '';
|
|
2556
|
+
const maxChars = Math.floor((w - 8) / 6.5);
|
|
2557
|
+
if (name.length > maxChars) return name.slice(0, maxChars - 1) + '\\u2026';
|
|
2558
|
+
return name;
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
// \u2500\u2500 Tab switching \u2500\u2500
|
|
2562
|
+
const modeDescriptions = {
|
|
2563
|
+
binary: 'Binary: Have you ever committed to this file? Yes (green) or No (red).',
|
|
2564
|
+
authorship: 'Authorship: How much of the current code did you write? Based on git blame line ownership.',
|
|
2565
|
+
weighted: 'Weighted: Combines blame ownership and commit history with adjustable weights. Use the sliders to tune.',
|
|
2566
|
+
};
|
|
2567
|
+
|
|
2568
|
+
function switchTab(tab) {
|
|
2569
|
+
activeTab = tab;
|
|
2570
|
+
document.querySelectorAll('#tabs .tab').forEach((el, i) => {
|
|
2571
|
+
const tabs = ['scoring', 'coverage', 'multiuser', 'hotspots'];
|
|
2572
|
+
el.classList.toggle('active', tabs[i] === tab);
|
|
2573
|
+
});
|
|
2574
|
+
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
|
|
2575
|
+
document.getElementById('tab-' + tab).classList.add('active');
|
|
2576
|
+
|
|
2577
|
+
// Show/hide tab descriptions
|
|
2578
|
+
['scoring', 'coverage', 'multiuser', 'hotspots'].forEach(t => {
|
|
2579
|
+
document.getElementById('tab-desc-' + t).classList.toggle('visible', t === tab);
|
|
2580
|
+
});
|
|
2581
|
+
|
|
2582
|
+
// Show/hide controls
|
|
2583
|
+
document.getElementById('scoring-controls').classList.toggle('visible', tab === 'scoring');
|
|
2584
|
+
document.getElementById('scoring-mode-desc').classList.toggle('visible', tab === 'scoring');
|
|
2585
|
+
document.getElementById('multiuser-controls').classList.toggle('visible', tab === 'multiuser');
|
|
2586
|
+
|
|
2587
|
+
// Show/hide breadcrumb
|
|
2588
|
+
const showBreadcrumb = tab === 'scoring' || tab === 'coverage' || tab === 'multiuser';
|
|
2589
|
+
document.getElementById('breadcrumb').classList.toggle('visible', showBreadcrumb);
|
|
2590
|
+
|
|
2591
|
+
// Show/hide legends
|
|
2592
|
+
document.getElementById('legend-scoring').classList.toggle('active', tab === 'scoring');
|
|
2593
|
+
document.getElementById('legend-coverage').classList.toggle('active', tab === 'coverage');
|
|
2594
|
+
document.getElementById('legend-multiuser').classList.toggle('active', tab === 'multiuser');
|
|
2595
|
+
|
|
2596
|
+
// Update breadcrumb for current tab
|
|
2597
|
+
if (tab === 'scoring') updateBreadcrumb(scoringPath);
|
|
2598
|
+
else if (tab === 'coverage') updateBreadcrumb(coveragePath);
|
|
2599
|
+
else if (tab === 'multiuser') updateBreadcrumb(multiuserPath);
|
|
2600
|
+
|
|
2601
|
+
// Render after a short delay so layout is computed after display change
|
|
2602
|
+
setTimeout(() => {
|
|
2603
|
+
if (!rendered[tab]) {
|
|
2604
|
+
rendered[tab] = true;
|
|
2605
|
+
if (tab === 'coverage') { renderCoverageSidebar(); renderCoverage(); }
|
|
2606
|
+
else if (tab === 'hotspots') { renderHotspotSidebar(); renderHotspot(); }
|
|
2607
|
+
else if (tab === 'multiuser') { initMultiUserSelect(); renderMultiUser(); }
|
|
2608
|
+
} else {
|
|
2609
|
+
if (tab === 'scoring') renderScoring();
|
|
2610
|
+
else if (tab === 'coverage') renderCoverage();
|
|
2611
|
+
else if (tab === 'hotspots') renderHotspot();
|
|
2612
|
+
else if (tab === 'multiuser') renderMultiUser();
|
|
2613
|
+
}
|
|
2614
|
+
}, 0);
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
// \u2500\u2500 Zoom (shared across treemap tabs) \u2500\u2500
|
|
2618
|
+
function zoomTo(path) {
|
|
2619
|
+
if (activeTab === 'scoring') { scoringPath = path; renderScoring(); }
|
|
2620
|
+
else if (activeTab === 'coverage') { coveragePath = path; renderCoverage(); }
|
|
2621
|
+
else if (activeTab === 'multiuser') { multiuserPath = path; renderMultiUser(); }
|
|
2622
|
+
updateBreadcrumb(path);
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
// \u2500\u2500 Layout dimensions \u2500\u2500
|
|
2626
|
+
function getContentHeight() {
|
|
2627
|
+
return document.getElementById('content-area').offsetHeight;
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2631
|
+
// \u2500\u2500 SCORING TAB \u2500\u2500
|
|
2632
|
+
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2633
|
+
|
|
2634
|
+
function switchScoringMode(mode) {
|
|
2635
|
+
scoringMode = mode;
|
|
2636
|
+
scoringPath = '';
|
|
2637
|
+
updateBreadcrumb('');
|
|
2638
|
+
document.querySelectorAll('#scoring-controls .subtab').forEach(el => {
|
|
2639
|
+
el.classList.toggle('active', el.textContent.toLowerCase() === mode);
|
|
2640
|
+
});
|
|
2641
|
+
document.getElementById('weight-controls').classList.toggle('visible', mode === 'weighted');
|
|
2642
|
+
document.getElementById('mode-desc-text').textContent = modeDescriptions[mode];
|
|
2643
|
+
renderScoring();
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
function onWeightChange() {
|
|
2647
|
+
const slider = document.getElementById('blame-slider');
|
|
2648
|
+
const bv = parseInt(slider.value);
|
|
2649
|
+
blameWeight = bv / 100;
|
|
2650
|
+
document.getElementById('blame-label').textContent = bv + '%';
|
|
2651
|
+
document.getElementById('commit-label').textContent = (100 - bv) + '%';
|
|
2652
|
+
recalcWeightedScores(scoringData.weighted, blameWeight, 1 - blameWeight);
|
|
2653
|
+
renderScoring();
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
function recalcWeightedScores(node, bw, cw) {
|
|
2657
|
+
if (node.type === 'file') {
|
|
2658
|
+
const bs = node.blameScore || 0;
|
|
2659
|
+
const cs = node.commitScore || 0;
|
|
2660
|
+
node.score = bw * bs + cw * cs;
|
|
2661
|
+
} else if (node.children) {
|
|
2662
|
+
let totalLines = 0;
|
|
2663
|
+
let weightedSum = 0;
|
|
2664
|
+
for (const child of node.children) {
|
|
2665
|
+
recalcWeightedScores(child, bw, cw);
|
|
2666
|
+
const lines = child.lines || 1;
|
|
2667
|
+
totalLines += lines;
|
|
2668
|
+
weightedSum += child.score * lines;
|
|
2669
|
+
}
|
|
2670
|
+
node.score = totalLines > 0 ? weightedSum / totalLines : 0;
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
function renderScoring() {
|
|
2675
|
+
const container = document.getElementById('tab-scoring');
|
|
2676
|
+
container.innerHTML = '';
|
|
2677
|
+
const height = getContentHeight();
|
|
2678
|
+
const width = window.innerWidth;
|
|
2679
|
+
|
|
2680
|
+
const treeData = scoringData[scoringMode];
|
|
2681
|
+
const targetNode = scoringPath ? findNode(treeData, scoringPath) : treeData;
|
|
2682
|
+
if (!targetNode || !targetNode.children || targetNode.children.length === 0) return;
|
|
2683
|
+
|
|
2684
|
+
const hierarchyData = {
|
|
2685
|
+
name: targetNode.path || 'root',
|
|
2686
|
+
children: targetNode.children.map(c => buildHierarchy(c)),
|
|
2687
|
+
};
|
|
2688
|
+
|
|
2689
|
+
const root = d3.hierarchy(hierarchyData)
|
|
2690
|
+
.sum(d => d.value || 0)
|
|
2691
|
+
.sort((a, b) => (b.value || 0) - (a.value || 0));
|
|
2692
|
+
|
|
2693
|
+
d3.treemap().size([width, height]).padding(2).paddingTop(20).round(true)(root);
|
|
2694
|
+
|
|
2695
|
+
const svg = d3.select('#tab-scoring').append('svg').attr('width', width).attr('height', height);
|
|
2696
|
+
const nodes = root.descendants().filter(d => d.depth > 0);
|
|
2697
|
+
|
|
2698
|
+
const groups = svg.selectAll('g').data(nodes).join('g')
|
|
2699
|
+
.attr('transform', d => 'translate(' + d.x0 + ',' + d.y0 + ')');
|
|
2700
|
+
|
|
2701
|
+
groups.append('rect')
|
|
2702
|
+
.attr('width', d => Math.max(0, d.x1 - d.x0))
|
|
2703
|
+
.attr('height', d => Math.max(0, d.y1 - d.y0))
|
|
2704
|
+
.attr('fill', d => d.data.data ? scoreColor(d.data.data.score) : '#333')
|
|
2705
|
+
.attr('opacity', d => d.children ? 0.35 : 0.88)
|
|
2706
|
+
.attr('stroke', '#1a1a2e').attr('stroke-width', d => d.children ? 1 : 0.5).attr('rx', 2)
|
|
2707
|
+
.style('cursor', d => (d.data.data && d.data.data.type === 'folder') ? 'pointer' : 'default')
|
|
2708
|
+
.on('click', (event, d) => {
|
|
2709
|
+
if (d.data.data && d.data.data.type === 'folder') { event.stopPropagation(); zoomTo(d.data.data.path); }
|
|
2710
|
+
})
|
|
2711
|
+
.on('mouseover', function(event, d) {
|
|
2712
|
+
if (!d.data.data) return;
|
|
2713
|
+
d3.select(this).attr('opacity', d.children ? 0.5 : 1).attr('stroke', '#fff');
|
|
2714
|
+
let html = '<strong>' + d.data.data.path + '</strong>';
|
|
2715
|
+
html += '<br>Score: ' + Math.round(d.data.data.score * 100) + '%';
|
|
2716
|
+
html += '<br>Lines: ' + d.data.data.lines.toLocaleString();
|
|
2717
|
+
if (d.data.data.type === 'folder') {
|
|
2718
|
+
html += '<br>Files: ' + d.data.data.fileCount;
|
|
2719
|
+
html += '<br><em style="color:#5eadf7">Click to drill down \\u25B6</em>';
|
|
2720
|
+
}
|
|
2721
|
+
if (d.data.data.blameScore !== undefined) html += '<br>Blame: ' + Math.round(d.data.data.blameScore * 100) + '%';
|
|
2722
|
+
if (d.data.data.commitScore !== undefined) html += '<br>Commit: ' + Math.round(d.data.data.commitScore * 100) + '%';
|
|
2723
|
+
if (d.data.data.isExpired) html += '<br><span style="color:#e94560">Expired</span>';
|
|
2724
|
+
showTooltipAt(html, event);
|
|
2725
|
+
})
|
|
2726
|
+
.on('mousemove', moveTooltip)
|
|
2727
|
+
.on('mouseout', function(event, d) {
|
|
2728
|
+
d3.select(this).attr('opacity', d.children ? 0.35 : 0.88).attr('stroke', '#1a1a2e');
|
|
2729
|
+
hideTooltip();
|
|
2730
|
+
});
|
|
2731
|
+
|
|
2732
|
+
groups.append('text')
|
|
2733
|
+
.attr('x', 4).attr('y', 14).attr('fill', '#fff')
|
|
2734
|
+
.attr('font-size', d => d.children ? '11px' : '10px')
|
|
2735
|
+
.attr('font-weight', d => d.children ? 'bold' : 'normal')
|
|
2736
|
+
.style('pointer-events', 'none')
|
|
2737
|
+
.text(d => truncateLabel(d.data.name || '', d.x1 - d.x0, d.y1 - d.y0));
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2741
|
+
// \u2500\u2500 COVERAGE TAB \u2500\u2500
|
|
2742
|
+
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2743
|
+
|
|
2744
|
+
function renderCoverage() {
|
|
2745
|
+
const vizArea = document.getElementById('coverage-viz');
|
|
2746
|
+
vizArea.innerHTML = '';
|
|
2747
|
+
let height = vizArea.offsetHeight;
|
|
2748
|
+
let width = vizArea.offsetWidth;
|
|
2749
|
+
|
|
2750
|
+
if (!width || !height) {
|
|
2751
|
+
const contentH = document.getElementById('content-area').offsetHeight;
|
|
2752
|
+
width = window.innerWidth - 300;
|
|
2753
|
+
height = contentH;
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
const targetNode = coveragePath ? findNode(coverageData, coveragePath) : coverageData;
|
|
2757
|
+
if (!targetNode || !targetNode.children || targetNode.children.length === 0) return;
|
|
2758
|
+
|
|
2759
|
+
const hierarchyData = {
|
|
2760
|
+
name: targetNode.path || 'root',
|
|
2761
|
+
children: targetNode.children.map(c => buildHierarchy(c)),
|
|
2762
|
+
};
|
|
2763
|
+
|
|
2764
|
+
const root = d3.hierarchy(hierarchyData)
|
|
2765
|
+
.sum(d => d.value || 0)
|
|
2766
|
+
.sort((a, b) => (b.value || 0) - (a.value || 0));
|
|
2767
|
+
|
|
2768
|
+
d3.treemap().size([width, height]).padding(2).paddingTop(20).round(true)(root);
|
|
2769
|
+
|
|
2770
|
+
const svg = d3.select('#coverage-viz').append('svg').attr('width', width).attr('height', height);
|
|
2771
|
+
const nodes = root.descendants().filter(d => d.depth > 0);
|
|
2772
|
+
|
|
2773
|
+
const groups = svg.selectAll('g').data(nodes).join('g')
|
|
2774
|
+
.attr('transform', d => 'translate(' + d.x0 + ',' + d.y0 + ')');
|
|
2775
|
+
|
|
2776
|
+
groups.append('rect')
|
|
2777
|
+
.attr('width', d => Math.max(0, d.x1 - d.x0))
|
|
2778
|
+
.attr('height', d => Math.max(0, d.y1 - d.y0))
|
|
2779
|
+
.attr('fill', d => {
|
|
2780
|
+
if (!d.data.data) return '#333';
|
|
2781
|
+
if (d.data.data.type === 'file') return coverageColor(d.data.data.contributorCount);
|
|
2782
|
+
return folderRiskColor(d.data.data.riskLevel);
|
|
2783
|
+
})
|
|
2784
|
+
.attr('opacity', d => d.children ? 0.35 : 0.88)
|
|
2785
|
+
.attr('stroke', '#1a1a2e').attr('stroke-width', d => d.children ? 1 : 0.5).attr('rx', 2)
|
|
2786
|
+
.style('cursor', d => (d.data.data && d.data.data.type === 'folder') ? 'pointer' : 'default')
|
|
2787
|
+
.on('click', (event, d) => {
|
|
2788
|
+
if (d.data.data && d.data.data.type === 'folder') { event.stopPropagation(); zoomTo(d.data.data.path); }
|
|
2789
|
+
})
|
|
2790
|
+
.on('mouseover', function(event, d) {
|
|
2791
|
+
if (!d.data.data) return;
|
|
2792
|
+
d3.select(this).attr('opacity', d.children ? 0.5 : 1).attr('stroke', '#fff');
|
|
2793
|
+
let html = '<strong>' + d.data.data.path + '</strong>';
|
|
2794
|
+
if (d.data.data.type === 'file') {
|
|
2795
|
+
html += '<br>Contributors: ' + d.data.data.contributorCount;
|
|
2796
|
+
if (d.data.data.contributors && d.data.data.contributors.length > 0) {
|
|
2797
|
+
html += '<br>' + d.data.data.contributors.slice(0, 8).join(', ');
|
|
2798
|
+
if (d.data.data.contributors.length > 8) html += ', ...';
|
|
2799
|
+
}
|
|
2800
|
+
html += '<br>Lines: ' + d.data.data.lines.toLocaleString();
|
|
2801
|
+
} else {
|
|
2802
|
+
html += '<br>Files: ' + d.data.data.fileCount;
|
|
2803
|
+
html += '<br>Avg Contributors: ' + d.data.data.avgContributors;
|
|
2804
|
+
html += '<br>Bus Factor: ' + d.data.data.busFactor;
|
|
2805
|
+
html += '<br><em style="color:#5eadf7">Click to drill down \\u25B6</em>';
|
|
2806
|
+
}
|
|
2807
|
+
showTooltipAt(html, event);
|
|
2808
|
+
})
|
|
2809
|
+
.on('mousemove', moveTooltip)
|
|
2810
|
+
.on('mouseout', function(event, d) {
|
|
2811
|
+
d3.select(this).attr('opacity', d.children ? 0.35 : 0.88).attr('stroke', '#1a1a2e');
|
|
2812
|
+
hideTooltip();
|
|
2813
|
+
});
|
|
2814
|
+
|
|
2815
|
+
groups.append('text')
|
|
2816
|
+
.attr('x', 4).attr('y', 14).attr('fill', '#fff')
|
|
2817
|
+
.attr('font-size', d => d.children ? '11px' : '10px')
|
|
2818
|
+
.attr('font-weight', d => d.children ? 'bold' : 'normal')
|
|
2819
|
+
.style('pointer-events', 'none')
|
|
2820
|
+
.text(d => truncateLabel(d.data.name || '', d.x1 - d.x0, d.y1 - d.y0));
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
function renderCoverageSidebar() {
|
|
2824
|
+
const container = document.getElementById('risk-list');
|
|
2825
|
+
if (coverageRiskFiles.length === 0) {
|
|
2826
|
+
container.innerHTML = '<div style="color:#888">No high-risk files found.</div>';
|
|
2827
|
+
return;
|
|
2828
|
+
}
|
|
2829
|
+
let html = '';
|
|
2830
|
+
for (const f of coverageRiskFiles.slice(0, 50)) {
|
|
2831
|
+
const countLabel = f.contributorCount === 0 ? '0 people' : '1 person (' + f.contributors[0] + ')';
|
|
2832
|
+
html += '<div class="risk-file"><div class="path">' + f.path + '</div><div class="meta">' + countLabel + '</div></div>';
|
|
2833
|
+
}
|
|
2834
|
+
if (coverageRiskFiles.length > 50) {
|
|
2835
|
+
html += '<div style="color:#888;padding:8px 0">... and ' + (coverageRiskFiles.length - 50) + ' more</div>';
|
|
2836
|
+
}
|
|
2837
|
+
container.innerHTML = html;
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2841
|
+
// \u2500\u2500 HOTSPOT TAB \u2500\u2500
|
|
2842
|
+
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2843
|
+
|
|
2844
|
+
function renderHotspot() {
|
|
2845
|
+
const vizArea = document.getElementById('hotspot-viz');
|
|
2846
|
+
const existingSvg = vizArea.querySelector('svg');
|
|
2847
|
+
if (existingSvg) existingSvg.remove();
|
|
2848
|
+
|
|
2849
|
+
let height = vizArea.offsetHeight;
|
|
2850
|
+
let width = vizArea.offsetWidth;
|
|
2851
|
+
|
|
2852
|
+
// Fallback: if the element hasn't been laid out yet, calculate manually
|
|
2853
|
+
if (!width || !height) {
|
|
2854
|
+
const contentH = document.getElementById('content-area').offsetHeight;
|
|
2855
|
+
const totalW = window.innerWidth;
|
|
2856
|
+
width = totalW - 300; // subtract sidebar width
|
|
2857
|
+
height = contentH;
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
const margin = { top: 30, right: 30, bottom: 60, left: 70 };
|
|
2861
|
+
const innerW = width - margin.left - margin.right;
|
|
2862
|
+
const innerH = height - margin.top - margin.bottom;
|
|
2863
|
+
|
|
2864
|
+
const maxFreq = d3.max(hotspotData, d => d.changeFrequency) || 1;
|
|
2865
|
+
|
|
2866
|
+
const x = d3.scaleLinear().domain([0, 1]).range([0, innerW]);
|
|
2867
|
+
const y = d3.scaleLinear().domain([0, maxFreq * 1.1]).range([innerH, 0]);
|
|
2868
|
+
const r = d3.scaleSqrt().domain([0, d3.max(hotspotData, d => d.lines) || 1]).range([3, 20]);
|
|
2869
|
+
|
|
2870
|
+
const svg = d3.select('#hotspot-viz').append('svg').attr('width', width).attr('height', height);
|
|
2871
|
+
const g = svg.append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
|
|
2872
|
+
|
|
2873
|
+
// Danger zone
|
|
2874
|
+
g.append('rect').attr('x', 0).attr('y', 0)
|
|
2875
|
+
.attr('width', x(0.3)).attr('height', y(maxFreq * 0.3))
|
|
2876
|
+
.attr('fill', 'rgba(233, 69, 96, 0.06)');
|
|
2877
|
+
|
|
2878
|
+
// Axes
|
|
2879
|
+
g.append('g').attr('transform', 'translate(0,' + innerH + ')')
|
|
2880
|
+
.call(d3.axisBottom(x).ticks(5).tickFormat(d => Math.round(d * 100) + '%'))
|
|
2881
|
+
.selectAll('text,line,path').attr('stroke', '#555').attr('fill', '#888');
|
|
2882
|
+
|
|
2883
|
+
svg.append('text').attr('x', margin.left + innerW / 2).attr('y', height - 10)
|
|
2884
|
+
.attr('text-anchor', 'middle').attr('fill', '#888').attr('font-size', '13px')
|
|
2885
|
+
.text('Familiarity \\u2192');
|
|
2886
|
+
|
|
2887
|
+
g.append('g').call(d3.axisLeft(y).ticks(6))
|
|
2888
|
+
.selectAll('text,line,path').attr('stroke', '#555').attr('fill', '#888');
|
|
2889
|
+
|
|
2890
|
+
svg.append('text').attr('transform', 'rotate(-90)')
|
|
2891
|
+
.attr('x', -(margin.top + innerH / 2)).attr('y', 16)
|
|
2892
|
+
.attr('text-anchor', 'middle').attr('fill', '#888').attr('font-size', '13px')
|
|
2893
|
+
.text('Change Frequency (commits) \\u2192');
|
|
2894
|
+
|
|
2895
|
+
// Zone labels
|
|
2896
|
+
const labels = document.getElementById('zone-labels');
|
|
2897
|
+
labels.innerHTML = '';
|
|
2898
|
+
const dangerLabel = document.createElement('div');
|
|
2899
|
+
dangerLabel.className = 'zone-label';
|
|
2900
|
+
dangerLabel.style.left = (margin.left + 8) + 'px';
|
|
2901
|
+
dangerLabel.style.top = (margin.top + 8) + 'px';
|
|
2902
|
+
dangerLabel.textContent = 'DANGER ZONE';
|
|
2903
|
+
dangerLabel.style.color = 'rgba(233,69,96,0.25)';
|
|
2904
|
+
labels.appendChild(dangerLabel);
|
|
2905
|
+
|
|
2906
|
+
const safeLabel = document.createElement('div');
|
|
2907
|
+
safeLabel.className = 'zone-label';
|
|
2908
|
+
safeLabel.style.right = (320 + 40) + 'px';
|
|
2909
|
+
safeLabel.style.bottom = (margin.bottom + 16) + 'px';
|
|
2910
|
+
safeLabel.textContent = 'SAFE ZONE';
|
|
2911
|
+
safeLabel.style.color = 'rgba(39,174,96,0.2)';
|
|
2912
|
+
labels.appendChild(safeLabel);
|
|
2913
|
+
|
|
2914
|
+
// Data points
|
|
2915
|
+
g.selectAll('circle').data(hotspotData).join('circle')
|
|
2916
|
+
.attr('cx', d => x(d.familiarity))
|
|
2917
|
+
.attr('cy', d => y(d.changeFrequency))
|
|
2918
|
+
.attr('r', d => r(d.lines))
|
|
2919
|
+
.attr('fill', d => riskLevelColor(d.riskLevel))
|
|
2920
|
+
.attr('opacity', 0.7)
|
|
2921
|
+
.attr('stroke', 'none')
|
|
2922
|
+
.style('cursor', 'pointer')
|
|
2923
|
+
.on('mouseover', function(event, d) {
|
|
2924
|
+
d3.select(this).attr('opacity', 1).attr('stroke', '#fff').attr('stroke-width', 2);
|
|
2925
|
+
showTooltipAt(
|
|
2926
|
+
'<strong>' + d.path + '</strong>' +
|
|
2927
|
+
'<br>Familiarity: ' + Math.round(d.familiarity * 100) + '%' +
|
|
2928
|
+
'<br>Changes: ' + d.changeFrequency + ' commits' +
|
|
2929
|
+
'<br>Risk: ' + d.risk.toFixed(2) + ' (' + d.riskLevel + ')' +
|
|
2930
|
+
'<br>Lines: ' + d.lines.toLocaleString(),
|
|
2931
|
+
event
|
|
2932
|
+
);
|
|
2933
|
+
})
|
|
2934
|
+
.on('mousemove', moveTooltip)
|
|
2935
|
+
.on('mouseout', function() {
|
|
2936
|
+
d3.select(this).attr('opacity', 0.7).attr('stroke', 'none');
|
|
2937
|
+
hideTooltip();
|
|
2938
|
+
});
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
function renderHotspotSidebar() {
|
|
2942
|
+
const container = document.getElementById('hotspot-list');
|
|
2943
|
+
const top = hotspotData.slice(0, 30);
|
|
2944
|
+
if (top.length === 0) {
|
|
2945
|
+
container.innerHTML = '<div style="color:#888">No active files in time window.</div>';
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
let html = '';
|
|
2949
|
+
for (let i = 0; i < top.length; i++) {
|
|
2950
|
+
const f = top[i];
|
|
2951
|
+
html += '<div class="hotspot-item"><div class="path">' + (i + 1) + '. ' + f.path +
|
|
2952
|
+
' <span class="risk-badge risk-' + f.riskLevel + '">' + f.riskLevel.toUpperCase() + '</span></div>' +
|
|
2953
|
+
'<div class="meta">Fam: ' + Math.round(f.familiarity * 100) + '% | Changes: ' + f.changeFrequency + ' | Risk: ' + f.risk.toFixed(2) + '</div></div>';
|
|
2954
|
+
}
|
|
2955
|
+
container.innerHTML = html;
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2959
|
+
// \u2500\u2500 MULTI-USER TAB \u2500\u2500
|
|
2960
|
+
// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550
|
|
2961
|
+
|
|
2962
|
+
function initMultiUserSelect() {
|
|
2963
|
+
const select = document.getElementById('userSelect');
|
|
2964
|
+
select.innerHTML = '';
|
|
2965
|
+
multiUserNames.forEach((name, i) => {
|
|
2966
|
+
const opt = document.createElement('option');
|
|
2967
|
+
opt.value = i;
|
|
2968
|
+
const summary = multiUserSummaries[i];
|
|
2969
|
+
opt.textContent = name + ' (' + Math.round(summary.overallScore * 100) + '%)';
|
|
2970
|
+
select.appendChild(opt);
|
|
2971
|
+
});
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
function onUserChange() {
|
|
2975
|
+
currentUser = parseInt(document.getElementById('userSelect').value);
|
|
2976
|
+
renderMultiUser();
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
function getUserScore(node) {
|
|
2980
|
+
if (!node.userScores || node.userScores.length === 0) return node.score;
|
|
2981
|
+
const s = node.userScores[currentUser];
|
|
2982
|
+
return s ? s.score : 0;
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
function renderMultiUser() {
|
|
2986
|
+
const container = document.getElementById('tab-multiuser');
|
|
2987
|
+
container.innerHTML = '';
|
|
2988
|
+
const height = getContentHeight();
|
|
2989
|
+
const width = window.innerWidth;
|
|
2990
|
+
|
|
2991
|
+
const targetNode = multiuserPath ? findNode(multiUserData, multiuserPath) : multiUserData;
|
|
2992
|
+
if (!targetNode || !targetNode.children || targetNode.children.length === 0) return;
|
|
2993
|
+
|
|
2994
|
+
const hierarchyData = {
|
|
2995
|
+
name: targetNode.path || 'root',
|
|
2996
|
+
children: targetNode.children.map(c => buildHierarchy(c)),
|
|
2997
|
+
};
|
|
2998
|
+
|
|
2999
|
+
const root = d3.hierarchy(hierarchyData)
|
|
3000
|
+
.sum(d => d.value || 0)
|
|
3001
|
+
.sort((a, b) => (b.value || 0) - (a.value || 0));
|
|
3002
|
+
|
|
3003
|
+
d3.treemap().size([width, height]).padding(2).paddingTop(20).round(true)(root);
|
|
3004
|
+
|
|
3005
|
+
const svg = d3.select('#tab-multiuser').append('svg').attr('width', width).attr('height', height);
|
|
3006
|
+
const nodes = root.descendants().filter(d => d.depth > 0);
|
|
3007
|
+
|
|
3008
|
+
const groups = svg.selectAll('g').data(nodes).join('g')
|
|
3009
|
+
.attr('transform', d => 'translate(' + d.x0 + ',' + d.y0 + ')');
|
|
3010
|
+
|
|
3011
|
+
groups.append('rect')
|
|
3012
|
+
.attr('width', d => Math.max(0, d.x1 - d.x0))
|
|
3013
|
+
.attr('height', d => Math.max(0, d.y1 - d.y0))
|
|
3014
|
+
.attr('fill', d => d.data.data ? scoreColor(getUserScore(d.data.data)) : '#333')
|
|
3015
|
+
.attr('opacity', d => d.children ? 0.35 : 0.88)
|
|
3016
|
+
.attr('stroke', '#1a1a2e').attr('stroke-width', d => d.children ? 1 : 0.5).attr('rx', 2)
|
|
3017
|
+
.style('cursor', d => (d.data.data && d.data.data.type === 'folder') ? 'pointer' : 'default')
|
|
3018
|
+
.on('click', (event, d) => {
|
|
3019
|
+
if (d.data.data && d.data.data.type === 'folder') { event.stopPropagation(); zoomTo(d.data.data.path); }
|
|
3020
|
+
})
|
|
3021
|
+
.on('mouseover', function(event, d) {
|
|
3022
|
+
if (!d.data.data) return;
|
|
3023
|
+
d3.select(this).attr('opacity', d.children ? 0.5 : 1).attr('stroke', '#fff');
|
|
3024
|
+
let html = '<strong>' + (d.data.data.path || 'root') + '</strong>';
|
|
3025
|
+
if (d.data.data.userScores && d.data.data.userScores.length > 0) {
|
|
3026
|
+
html += '<table style="margin-top:6px;width:100%">';
|
|
3027
|
+
d.data.data.userScores.forEach((s, i) => {
|
|
3028
|
+
const isCurrent = (i === currentUser);
|
|
3029
|
+
const style = isCurrent ? 'font-weight:bold;color:#5eadf7' : '';
|
|
3030
|
+
html += '<tr style="' + style + '"><td>' + multiUserNames[i] + '</td><td style="text-align:right">' + Math.round(s.score * 100) + '%</td></tr>';
|
|
3031
|
+
});
|
|
3032
|
+
html += '</table>';
|
|
3033
|
+
}
|
|
3034
|
+
if (d.data.data.type === 'folder') {
|
|
3035
|
+
html += '<br>Files: ' + d.data.data.fileCount;
|
|
3036
|
+
html += '<br><em style="color:#5eadf7">Click to drill down \\u25B6</em>';
|
|
3037
|
+
} else {
|
|
3038
|
+
html += '<br>Lines: ' + d.data.data.lines.toLocaleString();
|
|
3039
|
+
}
|
|
3040
|
+
showTooltipAt(html, event);
|
|
3041
|
+
})
|
|
3042
|
+
.on('mousemove', moveTooltip)
|
|
3043
|
+
.on('mouseout', function(event, d) {
|
|
3044
|
+
d3.select(this).attr('opacity', d.children ? 0.35 : 0.88).attr('stroke', '#1a1a2e');
|
|
3045
|
+
hideTooltip();
|
|
3046
|
+
});
|
|
3047
|
+
|
|
3048
|
+
groups.append('text')
|
|
3049
|
+
.attr('x', 4).attr('y', 14).attr('fill', '#fff')
|
|
3050
|
+
.attr('font-size', d => d.children ? '11px' : '10px')
|
|
3051
|
+
.attr('font-weight', d => d.children ? 'bold' : 'normal')
|
|
3052
|
+
.style('pointer-events', 'none')
|
|
3053
|
+
.text(d => truncateLabel(d.data.name || '', d.x1 - d.x0, d.y1 - d.y0));
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
// \u2500\u2500 Init \u2500\u2500
|
|
3057
|
+
rendered.scoring = true;
|
|
3058
|
+
renderScoring();
|
|
3059
|
+
window.addEventListener('resize', () => {
|
|
3060
|
+
if (activeTab === 'scoring') renderScoring();
|
|
3061
|
+
else if (activeTab === 'coverage') renderCoverage();
|
|
3062
|
+
else if (activeTab === 'hotspots') renderHotspot();
|
|
3063
|
+
else if (activeTab === 'multiuser') renderMultiUser();
|
|
3064
|
+
});
|
|
3065
|
+
</script>
|
|
3066
|
+
</body>
|
|
3067
|
+
</html>`;
|
|
3068
|
+
}
|
|
3069
|
+
async function generateAndOpenUnifiedHTML(data, repoPath) {
|
|
3070
|
+
const html = generateUnifiedHTML(data);
|
|
3071
|
+
const outputPath = join5(repoPath, "gitfamiliar-dashboard.html");
|
|
3072
|
+
writeFileSync5(outputPath, html, "utf-8");
|
|
3073
|
+
console.log(`Dashboard generated: ${outputPath}`);
|
|
3074
|
+
await openBrowser(outputPath);
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
// src/cli/index.ts
|
|
3078
|
+
function collect(value, previous) {
|
|
3079
|
+
return previous.concat([value]);
|
|
3080
|
+
}
|
|
3081
|
+
function createProgram() {
|
|
2209
3082
|
const program2 = new Command();
|
|
2210
3083
|
program2.name("gitfamiliar").description("Visualize your code familiarity from Git history").version("0.1.1").option(
|
|
2211
3084
|
"-m, --mode <mode>",
|
|
2212
|
-
"Scoring mode: binary, authorship,
|
|
3085
|
+
"Scoring mode: binary, authorship, weighted",
|
|
2213
3086
|
"binary"
|
|
2214
3087
|
).option(
|
|
2215
3088
|
"-u, --user <user>",
|
|
2216
3089
|
"Git user name or email (repeatable for comparison)",
|
|
2217
3090
|
collect,
|
|
2218
3091
|
[]
|
|
2219
|
-
).option(
|
|
2220
|
-
"-f, --filter <filter>",
|
|
2221
|
-
"Filter mode: all, written, reviewed",
|
|
2222
|
-
"all"
|
|
2223
3092
|
).option(
|
|
2224
3093
|
"-e, --expiration <policy>",
|
|
2225
3094
|
"Expiration policy: never, time:180d, change:50%, combined:365d:50%",
|
|
2226
3095
|
"never"
|
|
2227
3096
|
).option("--html", "Generate HTML treemap report", false).option(
|
|
2228
3097
|
"-w, --weights <weights>",
|
|
2229
|
-
'Weights for weighted mode: blame,commit
|
|
3098
|
+
'Weights for weighted mode: blame,commit (e.g., "0.5,0.5")'
|
|
2230
3099
|
).option("--team", "Compare all contributors", false).option(
|
|
2231
3100
|
"--team-coverage",
|
|
2232
3101
|
"Show team coverage map (bus factor analysis)",
|
|
@@ -2234,19 +3103,14 @@ function createProgram() {
|
|
|
2234
3103
|
).option("--hotspot [mode]", "Hotspot analysis: personal (default) or team").option(
|
|
2235
3104
|
"--window <days>",
|
|
2236
3105
|
"Time window for hotspot analysis in days (default: 90)"
|
|
2237
|
-
).option(
|
|
2238
|
-
"--github-url <hostname>",
|
|
2239
|
-
"GitHub Enterprise hostname (e.g. ghe.example.com). Auto-detected from git remote if omitted."
|
|
2240
|
-
).option(
|
|
2241
|
-
"--check-github",
|
|
2242
|
-
"Verify GitHub API connectivity and show connection info",
|
|
2243
|
-
false
|
|
2244
3106
|
).action(async (rawOptions) => {
|
|
2245
3107
|
try {
|
|
2246
3108
|
const repoPath = process.cwd();
|
|
2247
3109
|
const options = parseOptions(rawOptions, repoPath);
|
|
2248
|
-
|
|
2249
|
-
|
|
3110
|
+
const isMultiUserCheck = options.team || Array.isArray(options.user) && options.user.length > 1;
|
|
3111
|
+
if (options.html && !options.hotspot && !options.teamCoverage && !isMultiUserCheck) {
|
|
3112
|
+
const data = await computeUnified(options);
|
|
3113
|
+
await generateAndOpenUnifiedHTML(data, repoPath);
|
|
2250
3114
|
return;
|
|
2251
3115
|
}
|
|
2252
3116
|
if (options.hotspot) {
|