gitfamiliar 0.7.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/dist/bin/gitfamiliar.js
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
processBatch,
|
|
9
9
|
resolveUser,
|
|
10
10
|
walkFiles
|
|
11
|
-
} from "../chunk-
|
|
11
|
+
} from "../chunk-R5MGQGFI.js";
|
|
12
12
|
|
|
13
13
|
// src/cli/index.ts
|
|
14
14
|
import { Command } from "commander";
|
|
@@ -108,9 +108,11 @@ function getModeLabel(mode) {
|
|
|
108
108
|
return mode;
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
|
+
var NAME_COLUMN_WIDTH = 24;
|
|
111
112
|
function renderFolder(node, indent, mode, maxDepth) {
|
|
112
113
|
const lines = [];
|
|
113
114
|
const prefix = " ".repeat(indent);
|
|
115
|
+
const prefixWidth = indent * 2;
|
|
114
116
|
const sorted = [...node.children].sort((a, b) => {
|
|
115
117
|
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
|
|
116
118
|
return a.path.localeCompare(b.path);
|
|
@@ -121,14 +123,19 @@ function renderFolder(node, indent, mode, maxDepth) {
|
|
|
121
123
|
const name = folder.path.split("/").pop() + "/";
|
|
122
124
|
const bar = makeBar(folder.score);
|
|
123
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);
|
|
124
131
|
if (mode === "binary") {
|
|
125
132
|
const readCount = folder.readCount || 0;
|
|
126
133
|
lines.push(
|
|
127
|
-
`${prefix}${chalk.bold(name
|
|
134
|
+
`${prefix}${chalk.bold(name)}${padding} ${bar} ${pct.padStart(4)} (${readCount}/${folder.fileCount} files)`
|
|
128
135
|
);
|
|
129
136
|
} else {
|
|
130
137
|
lines.push(
|
|
131
|
-
`${prefix}${chalk.bold(name
|
|
138
|
+
`${prefix}${chalk.bold(name)}${padding} ${bar} ${pct.padStart(4)}`
|
|
132
139
|
);
|
|
133
140
|
}
|
|
134
141
|
if (indent < maxDepth) {
|
|
@@ -2075,39 +2082,1037 @@ async function generateAndOpenHotspotHTML(result, repoPath) {
|
|
|
2075
2082
|
await openBrowser(outputPath);
|
|
2076
2083
|
}
|
|
2077
2084
|
|
|
2078
|
-
// src/
|
|
2079
|
-
function
|
|
2080
|
-
|
|
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
|
+
};
|
|
2081
2115
|
}
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
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;
|
|
2157
|
+
}
|
|
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;
|
|
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);
|
|
2475
|
+
}
|
|
2476
|
+
|
|
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';
|
|
2482
|
+
}
|
|
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() {
|
|
3082
|
+
const program2 = new Command();
|
|
3083
|
+
program2.name("gitfamiliar").description("Visualize your code familiarity from Git history").version("0.1.1").option(
|
|
3084
|
+
"-m, --mode <mode>",
|
|
3085
|
+
"Scoring mode: binary, authorship, weighted",
|
|
3086
|
+
"binary"
|
|
3087
|
+
).option(
|
|
3088
|
+
"-u, --user <user>",
|
|
3089
|
+
"Git user name or email (repeatable for comparison)",
|
|
3090
|
+
collect,
|
|
3091
|
+
[]
|
|
3092
|
+
).option(
|
|
3093
|
+
"-e, --expiration <policy>",
|
|
3094
|
+
"Expiration policy: never, time:180d, change:50%, combined:365d:50%",
|
|
3095
|
+
"never"
|
|
3096
|
+
).option("--html", "Generate HTML treemap report", false).option(
|
|
3097
|
+
"-w, --weights <weights>",
|
|
3098
|
+
'Weights for weighted mode: blame,commit (e.g., "0.5,0.5")'
|
|
3099
|
+
).option("--team", "Compare all contributors", false).option(
|
|
3100
|
+
"--team-coverage",
|
|
3101
|
+
"Show team coverage map (bus factor analysis)",
|
|
3102
|
+
false
|
|
3103
|
+
).option("--hotspot [mode]", "Hotspot analysis: personal (default) or team").option(
|
|
3104
|
+
"--window <days>",
|
|
3105
|
+
"Time window for hotspot analysis in days (default: 90)"
|
|
3106
|
+
).action(async (rawOptions) => {
|
|
3107
|
+
try {
|
|
3108
|
+
const repoPath = process.cwd();
|
|
3109
|
+
const options = parseOptions(rawOptions, repoPath);
|
|
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);
|
|
3114
|
+
return;
|
|
3115
|
+
}
|
|
2111
3116
|
if (options.hotspot) {
|
|
2112
3117
|
const result2 = await computeHotspots(options);
|
|
2113
3118
|
if (options.html) {
|