gitfamiliar 0.7.0 → 0.9.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 +109 -78
- package/dist/bin/gitfamiliar.js +1300 -40
- package/dist/bin/gitfamiliar.js.map +1 -1
- package/dist/{chunk-BQCHOSLA.js → chunk-NGCQUB2H.js} +1 -1
- package/dist/chunk-NGCQUB2H.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-BQCHOSLA.js.map +0 -1
package/dist/bin/gitfamiliar.js
CHANGED
|
@@ -8,9 +8,10 @@ import {
|
|
|
8
8
|
processBatch,
|
|
9
9
|
resolveUser,
|
|
10
10
|
walkFiles
|
|
11
|
-
} from "../chunk-
|
|
11
|
+
} from "../chunk-NGCQUB2H.js";
|
|
12
12
|
|
|
13
13
|
// src/cli/index.ts
|
|
14
|
+
import { createRequire } from "module";
|
|
14
15
|
import { Command } from "commander";
|
|
15
16
|
|
|
16
17
|
// src/core/types.ts
|
|
@@ -108,9 +109,11 @@ function getModeLabel(mode) {
|
|
|
108
109
|
return mode;
|
|
109
110
|
}
|
|
110
111
|
}
|
|
112
|
+
var NAME_COLUMN_WIDTH = 24;
|
|
111
113
|
function renderFolder(node, indent, mode, maxDepth) {
|
|
112
114
|
const lines = [];
|
|
113
115
|
const prefix = " ".repeat(indent);
|
|
116
|
+
const prefixWidth = indent * 2;
|
|
114
117
|
const sorted = [...node.children].sort((a, b) => {
|
|
115
118
|
if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
|
|
116
119
|
return a.path.localeCompare(b.path);
|
|
@@ -121,14 +124,19 @@ function renderFolder(node, indent, mode, maxDepth) {
|
|
|
121
124
|
const name = folder.path.split("/").pop() + "/";
|
|
122
125
|
const bar = makeBar(folder.score);
|
|
123
126
|
const pct = formatPercent(folder.score);
|
|
127
|
+
const padWidth = Math.max(
|
|
128
|
+
1,
|
|
129
|
+
NAME_COLUMN_WIDTH - prefixWidth - name.length
|
|
130
|
+
);
|
|
131
|
+
const padding = " ".repeat(padWidth);
|
|
124
132
|
if (mode === "binary") {
|
|
125
133
|
const readCount = folder.readCount || 0;
|
|
126
134
|
lines.push(
|
|
127
|
-
`${prefix}${chalk.bold(name
|
|
135
|
+
`${prefix}${chalk.bold(name)}${padding} ${bar} ${pct.padStart(4)} (${readCount}/${folder.fileCount} files)`
|
|
128
136
|
);
|
|
129
137
|
} else {
|
|
130
138
|
lines.push(
|
|
131
|
-
`${prefix}${chalk.bold(name
|
|
139
|
+
`${prefix}${chalk.bold(name)}${padding} ${bar} ${pct.padStart(4)}`
|
|
132
140
|
);
|
|
133
141
|
}
|
|
134
142
|
if (indent < maxDepth) {
|
|
@@ -1623,14 +1631,26 @@ async function computeHotspots(options) {
|
|
|
1623
1631
|
const isTeamMode = options.hotspot === "team";
|
|
1624
1632
|
const trackedFiles = /* @__PURE__ */ new Set();
|
|
1625
1633
|
walkFiles(tree, (f) => trackedFiles.add(f.path));
|
|
1626
|
-
const changeFreqMap = await bulkGetChangeFrequency(
|
|
1634
|
+
const changeFreqMap = await bulkGetChangeFrequency(
|
|
1635
|
+
gitClient,
|
|
1636
|
+
timeWindow,
|
|
1637
|
+
trackedFiles
|
|
1638
|
+
);
|
|
1627
1639
|
let familiarityMap;
|
|
1628
1640
|
let userName;
|
|
1629
1641
|
if (isTeamMode) {
|
|
1630
|
-
familiarityMap = await computeTeamAvgFamiliarity(
|
|
1642
|
+
familiarityMap = await computeTeamAvgFamiliarity(
|
|
1643
|
+
gitClient,
|
|
1644
|
+
trackedFiles,
|
|
1645
|
+
options
|
|
1646
|
+
);
|
|
1631
1647
|
} else {
|
|
1632
1648
|
const userFlag = Array.isArray(options.user) ? options.user[0] : options.user;
|
|
1633
|
-
const result = await computeFamiliarity({
|
|
1649
|
+
const result = await computeFamiliarity({
|
|
1650
|
+
...options,
|
|
1651
|
+
team: false,
|
|
1652
|
+
teamCoverage: false
|
|
1653
|
+
});
|
|
1634
1654
|
userName = result.userName;
|
|
1635
1655
|
familiarityMap = /* @__PURE__ */ new Map();
|
|
1636
1656
|
walkFiles(result.tree, (f) => {
|
|
@@ -1686,12 +1706,18 @@ function classifyHotspotRisk(risk) {
|
|
|
1686
1706
|
async function computeTeamAvgFamiliarity(gitClient, trackedFiles, options) {
|
|
1687
1707
|
const contributors = await getAllContributors(gitClient, 1);
|
|
1688
1708
|
const totalContributors = Math.max(1, contributors.length);
|
|
1689
|
-
const fileContributors = await bulkGetFileContributors(
|
|
1709
|
+
const fileContributors = await bulkGetFileContributors(
|
|
1710
|
+
gitClient,
|
|
1711
|
+
trackedFiles
|
|
1712
|
+
);
|
|
1690
1713
|
const result = /* @__PURE__ */ new Map();
|
|
1691
1714
|
for (const filePath of trackedFiles) {
|
|
1692
1715
|
const contribs = fileContributors.get(filePath);
|
|
1693
1716
|
const count = contribs ? contribs.size : 0;
|
|
1694
|
-
result.set(
|
|
1717
|
+
result.set(
|
|
1718
|
+
filePath,
|
|
1719
|
+
Math.min(1, count / Math.max(1, totalContributors * 0.3))
|
|
1720
|
+
);
|
|
1695
1721
|
}
|
|
1696
1722
|
return result;
|
|
1697
1723
|
}
|
|
@@ -2075,39 +2101,1273 @@ async function generateAndOpenHotspotHTML(result, repoPath) {
|
|
|
2075
2101
|
await openBrowser(outputPath);
|
|
2076
2102
|
}
|
|
2077
2103
|
|
|
2078
|
-
// src/
|
|
2079
|
-
function
|
|
2080
|
-
|
|
2104
|
+
// src/core/unified.ts
|
|
2105
|
+
async function computeUnified(options) {
|
|
2106
|
+
console.log("Computing unified dashboard data...");
|
|
2107
|
+
console.log(" [1/4] Scoring (binary, authorship, weighted)...");
|
|
2108
|
+
const [binary, authorship, weighted] = await Promise.all([
|
|
2109
|
+
computeFamiliarity({ ...options, mode: "binary" }),
|
|
2110
|
+
computeFamiliarity({ ...options, mode: "authorship" }),
|
|
2111
|
+
computeFamiliarity({ ...options, mode: "weighted" })
|
|
2112
|
+
]);
|
|
2113
|
+
console.log(" [2/4] Team coverage...");
|
|
2114
|
+
const coverage = await computeTeamCoverage(options);
|
|
2115
|
+
console.log(" [3/5] Hotspot analysis...");
|
|
2116
|
+
const hotspot = await computeHotspots({
|
|
2117
|
+
...options,
|
|
2118
|
+
hotspot: "personal"
|
|
2119
|
+
});
|
|
2120
|
+
console.log(" [4/5] Hotspot team familiarity...");
|
|
2121
|
+
const gitClient = new GitClient(options.repoPath);
|
|
2122
|
+
const repoRoot = await gitClient.getRepoRoot();
|
|
2123
|
+
const filter = createFilter(repoRoot);
|
|
2124
|
+
const tree = await buildFileTree(gitClient, filter);
|
|
2125
|
+
const trackedFiles = /* @__PURE__ */ new Set();
|
|
2126
|
+
walkFiles(tree, (f) => trackedFiles.add(f.path));
|
|
2127
|
+
const teamFamMap = await computeTeamAvgFamiliarity(
|
|
2128
|
+
gitClient,
|
|
2129
|
+
trackedFiles,
|
|
2130
|
+
options
|
|
2131
|
+
);
|
|
2132
|
+
const hotspotTeamFamiliarity = {};
|
|
2133
|
+
for (const [k, v] of teamFamMap) hotspotTeamFamiliarity[k] = v;
|
|
2134
|
+
console.log(" [5/5] Multi-user comparison...");
|
|
2135
|
+
const multiUser = await computeMultiUser({
|
|
2136
|
+
...options,
|
|
2137
|
+
team: true
|
|
2138
|
+
});
|
|
2139
|
+
console.log("Done.");
|
|
2140
|
+
return {
|
|
2141
|
+
repoName: binary.repoName,
|
|
2142
|
+
userName: binary.userName,
|
|
2143
|
+
scoring: { binary, authorship, weighted },
|
|
2144
|
+
coverage,
|
|
2145
|
+
hotspot,
|
|
2146
|
+
hotspotTeamFamiliarity,
|
|
2147
|
+
multiUser
|
|
2148
|
+
};
|
|
2081
2149
|
}
|
|
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
|
-
|
|
2150
|
+
|
|
2151
|
+
// src/cli/output/unified-html.ts
|
|
2152
|
+
import { writeFileSync as writeFileSync5 } from "fs";
|
|
2153
|
+
import { join as join5 } from "path";
|
|
2154
|
+
function generateUnifiedHTML(data) {
|
|
2155
|
+
const scoringBinaryJson = JSON.stringify(data.scoring.binary.tree);
|
|
2156
|
+
const scoringAuthorshipJson = JSON.stringify(data.scoring.authorship.tree);
|
|
2157
|
+
const scoringWeightedJson = JSON.stringify(data.scoring.weighted.tree);
|
|
2158
|
+
const coverageTreeJson = JSON.stringify(data.coverage.tree);
|
|
2159
|
+
const coverageRiskJson = JSON.stringify(data.coverage.riskFiles);
|
|
2160
|
+
const hotspotJson = JSON.stringify(
|
|
2161
|
+
data.hotspot.files.filter((f) => f.changeFrequency > 0).map((f) => ({
|
|
2162
|
+
path: f.path,
|
|
2163
|
+
lines: f.lines,
|
|
2164
|
+
familiarity: f.familiarity,
|
|
2165
|
+
changeFrequency: f.changeFrequency,
|
|
2166
|
+
risk: f.risk,
|
|
2167
|
+
riskLevel: f.riskLevel
|
|
2168
|
+
}))
|
|
2169
|
+
);
|
|
2170
|
+
const hotspotTeamFamJson = JSON.stringify(data.hotspotTeamFamiliarity);
|
|
2171
|
+
const multiUserTreeJson = JSON.stringify(data.multiUser.tree);
|
|
2172
|
+
const multiUserSummariesJson = JSON.stringify(data.multiUser.userSummaries);
|
|
2173
|
+
const multiUserNamesJson = JSON.stringify(
|
|
2174
|
+
data.multiUser.users.map((u) => u.name)
|
|
2175
|
+
);
|
|
2176
|
+
return `<!DOCTYPE html>
|
|
2177
|
+
<html lang="en">
|
|
2178
|
+
<head>
|
|
2179
|
+
<meta charset="UTF-8">
|
|
2180
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2181
|
+
<title>GitFamiliar \u2014 ${data.repoName}</title>
|
|
2182
|
+
<style>
|
|
2183
|
+
:root {
|
|
2184
|
+
--bg-base: #1a1a2e;
|
|
2185
|
+
--bg-panel: #16213e;
|
|
2186
|
+
--accent: #e94560;
|
|
2187
|
+
--accent-hover: #ff5577;
|
|
2188
|
+
--border: #0f3460;
|
|
2189
|
+
--text-primary: #e0e0e0;
|
|
2190
|
+
--text-secondary: #a0a0a0;
|
|
2191
|
+
--text-dim: #888;
|
|
2192
|
+
--link: #5eadf7;
|
|
2193
|
+
--color-critical: #e94560;
|
|
2194
|
+
--color-high: #f07040;
|
|
2195
|
+
--color-medium: #f5a623;
|
|
2196
|
+
--color-safe: #27ae60;
|
|
2197
|
+
--shadow-sm: 0 2px 4px rgba(0,0,0,0.3);
|
|
2198
|
+
--shadow-md: 0 4px 12px rgba(0,0,0,0.4);
|
|
2199
|
+
--shadow-lg: 0 8px 24px rgba(0,0,0,0.5);
|
|
2200
|
+
--shadow-glow-accent: 0 0 20px rgba(233,69,96,0.3);
|
|
2201
|
+
--glass-bg: rgba(22,33,62,0.85);
|
|
2202
|
+
--glass-border: rgba(94,173,247,0.15);
|
|
2203
|
+
--transition-fast: 0.15s ease;
|
|
2204
|
+
--transition-smooth: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
2205
|
+
}
|
|
2206
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
2207
|
+
body {
|
|
2208
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
2209
|
+
background: var(--bg-base);
|
|
2210
|
+
color: var(--text-primary);
|
|
2211
|
+
overflow: hidden;
|
|
2212
|
+
display: flex;
|
|
2213
|
+
flex-direction: column;
|
|
2214
|
+
height: 100vh;
|
|
2215
|
+
}
|
|
2216
|
+
#header {
|
|
2217
|
+
padding: 12px 24px;
|
|
2218
|
+
background: linear-gradient(135deg, var(--bg-panel) 0%, #1a2844 100%);
|
|
2219
|
+
border-bottom: 1px solid var(--border);
|
|
2220
|
+
box-shadow: var(--shadow-md);
|
|
2221
|
+
display: flex;
|
|
2222
|
+
align-items: center;
|
|
2223
|
+
justify-content: space-between;
|
|
2224
|
+
position: relative;
|
|
2225
|
+
z-index: 10;
|
|
2226
|
+
}
|
|
2227
|
+
#header h1 { font-size: 18px; color: var(--accent); text-shadow: 0 0 20px rgba(233,69,96,0.4); }
|
|
2228
|
+
#header .info { font-size: 13px; color: var(--text-secondary); }
|
|
2229
|
+
|
|
2230
|
+
/* Tabs */
|
|
2231
|
+
#tabs {
|
|
2232
|
+
display: flex;
|
|
2233
|
+
background: var(--bg-panel);
|
|
2234
|
+
border-bottom: 1px solid var(--border);
|
|
2235
|
+
padding: 0 24px;
|
|
2236
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
2237
|
+
position: relative;
|
|
2238
|
+
z-index: 9;
|
|
2239
|
+
}
|
|
2240
|
+
#tabs .tab {
|
|
2241
|
+
padding: 10px 20px;
|
|
2242
|
+
cursor: pointer;
|
|
2243
|
+
color: var(--text-dim);
|
|
2244
|
+
border-bottom: 2px solid transparent;
|
|
2245
|
+
font-size: 14px;
|
|
2246
|
+
transition: all var(--transition-smooth);
|
|
2247
|
+
position: relative;
|
|
2248
|
+
}
|
|
2249
|
+
#tabs .tab:hover { color: var(--text-primary); transform: translateY(-1px); }
|
|
2250
|
+
#tabs .tab.active {
|
|
2251
|
+
color: var(--accent);
|
|
2252
|
+
border-bottom-color: var(--accent);
|
|
2253
|
+
text-shadow: 0 0 10px rgba(233,69,96,0.5);
|
|
2254
|
+
background: linear-gradient(to bottom, transparent, rgba(233,69,96,0.05));
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
/* Sub-tabs (scoring modes) */
|
|
2258
|
+
#scoring-controls {
|
|
2259
|
+
display: none;
|
|
2260
|
+
padding: 8px 24px;
|
|
2261
|
+
background: var(--bg-panel);
|
|
2262
|
+
border-bottom: 1px solid var(--border);
|
|
2263
|
+
align-items: center;
|
|
2264
|
+
gap: 16px;
|
|
2265
|
+
}
|
|
2266
|
+
#scoring-controls.visible { display: flex; }
|
|
2267
|
+
.subtab {
|
|
2268
|
+
padding: 5px 14px;
|
|
2269
|
+
cursor: pointer;
|
|
2270
|
+
color: var(--text-dim);
|
|
2271
|
+
border: 1px solid var(--border);
|
|
2272
|
+
border-radius: 4px;
|
|
2273
|
+
font-size: 12px;
|
|
2274
|
+
background: transparent;
|
|
2275
|
+
transition: all var(--transition-smooth);
|
|
2276
|
+
box-shadow: var(--shadow-sm);
|
|
2277
|
+
}
|
|
2278
|
+
.subtab:hover { color: var(--text-primary); border-color: #555; transform: translateY(-1px); box-shadow: 0 4px 8px rgba(0,0,0,0.3); }
|
|
2279
|
+
.subtab.active { color: var(--accent); border-color: var(--accent); background: rgba(233,69,96,0.1); box-shadow: 0 0 12px rgba(233,69,96,0.3); }
|
|
2280
|
+
#weight-controls {
|
|
2281
|
+
display: none;
|
|
2282
|
+
align-items: center;
|
|
2283
|
+
gap: 8px;
|
|
2284
|
+
margin-left: 24px;
|
|
2285
|
+
font-size: 12px;
|
|
2286
|
+
color: var(--text-secondary);
|
|
2287
|
+
}
|
|
2288
|
+
#weight-controls.visible { display: flex; }
|
|
2289
|
+
#weight-controls input[type="range"] {
|
|
2290
|
+
width: 120px;
|
|
2291
|
+
-webkit-appearance: none;
|
|
2292
|
+
appearance: none;
|
|
2293
|
+
background: transparent;
|
|
2294
|
+
cursor: pointer;
|
|
2295
|
+
}
|
|
2296
|
+
#weight-controls input[type="range"]::-webkit-slider-runnable-track {
|
|
2297
|
+
height: 6px;
|
|
2298
|
+
background: linear-gradient(to right, var(--accent), var(--link));
|
|
2299
|
+
border-radius: 3px;
|
|
2300
|
+
box-shadow: inset 0 1px 3px rgba(0,0,0,0.3);
|
|
2301
|
+
}
|
|
2302
|
+
#weight-controls input[type="range"]::-moz-range-track {
|
|
2303
|
+
height: 6px;
|
|
2304
|
+
background: linear-gradient(to right, var(--accent), var(--link));
|
|
2305
|
+
border-radius: 3px;
|
|
2306
|
+
box-shadow: inset 0 1px 3px rgba(0,0,0,0.3);
|
|
2307
|
+
}
|
|
2308
|
+
#weight-controls input[type="range"]::-webkit-slider-thumb {
|
|
2309
|
+
-webkit-appearance: none;
|
|
2310
|
+
width: 16px;
|
|
2311
|
+
height: 16px;
|
|
2312
|
+
background: var(--accent);
|
|
2313
|
+
border-radius: 50%;
|
|
2314
|
+
cursor: pointer;
|
|
2315
|
+
margin-top: -5px;
|
|
2316
|
+
box-shadow: 0 2px 6px rgba(0,0,0,0.4), 0 0 8px rgba(233,69,96,0.4);
|
|
2317
|
+
transition: all var(--transition-fast);
|
|
2318
|
+
}
|
|
2319
|
+
#weight-controls input[type="range"]::-moz-range-thumb {
|
|
2320
|
+
width: 16px;
|
|
2321
|
+
height: 16px;
|
|
2322
|
+
background: var(--accent);
|
|
2323
|
+
border: none;
|
|
2324
|
+
border-radius: 50%;
|
|
2325
|
+
cursor: pointer;
|
|
2326
|
+
box-shadow: 0 2px 6px rgba(0,0,0,0.4), 0 0 8px rgba(233,69,96,0.4);
|
|
2327
|
+
transition: all var(--transition-fast);
|
|
2328
|
+
}
|
|
2329
|
+
#weight-controls input[type="range"]::-webkit-slider-thumb:hover {
|
|
2330
|
+
transform: scale(1.2);
|
|
2331
|
+
box-shadow: 0 4px 10px rgba(0,0,0,0.5), 0 0 16px rgba(233,69,96,0.6);
|
|
2332
|
+
}
|
|
2333
|
+
#weight-controls input[type="range"]::-moz-range-thumb:hover {
|
|
2334
|
+
transform: scale(1.2);
|
|
2335
|
+
box-shadow: 0 4px 10px rgba(0,0,0,0.5), 0 0 16px rgba(233,69,96,0.6);
|
|
2336
|
+
}
|
|
2337
|
+
#weight-controls .weight-label { min-width: 36px; text-align: right; color: var(--text-primary); }
|
|
2338
|
+
|
|
2339
|
+
/* Breadcrumb */
|
|
2340
|
+
#breadcrumb {
|
|
2341
|
+
padding: 8px 24px;
|
|
2342
|
+
background: var(--bg-panel);
|
|
2343
|
+
font-size: 13px;
|
|
2344
|
+
border-bottom: 1px solid var(--border);
|
|
2345
|
+
display: none;
|
|
2346
|
+
}
|
|
2347
|
+
#breadcrumb.visible { display: block; }
|
|
2348
|
+
#breadcrumb span {
|
|
2349
|
+
cursor: pointer;
|
|
2350
|
+
color: var(--link);
|
|
2351
|
+
padding: 3px 10px;
|
|
2352
|
+
border-radius: 12px;
|
|
2353
|
+
transition: all var(--transition-fast);
|
|
2354
|
+
display: inline-block;
|
|
2355
|
+
}
|
|
2356
|
+
#breadcrumb span:hover { background: rgba(94,173,247,0.12); text-shadow: 0 0 8px rgba(94,173,247,0.4); }
|
|
2357
|
+
#breadcrumb .sep { color: var(--text-dim); margin: 0 2px; padding: 0; }
|
|
2358
|
+
#breadcrumb .sep:hover { background: transparent; text-shadow: none; }
|
|
2359
|
+
|
|
2360
|
+
/* Tab descriptions */
|
|
2361
|
+
.tab-desc {
|
|
2362
|
+
padding: 8px 24px;
|
|
2363
|
+
background: var(--bg-panel);
|
|
2364
|
+
border-bottom: 1px solid var(--border);
|
|
2365
|
+
font-size: 12px;
|
|
2366
|
+
color: var(--text-dim);
|
|
2367
|
+
display: none;
|
|
2368
|
+
}
|
|
2369
|
+
.tab-desc.visible { display: block; }
|
|
2370
|
+
|
|
2371
|
+
/* Tab content */
|
|
2372
|
+
#content-area { flex: 1; position: relative; overflow: hidden; }
|
|
2373
|
+
.tab-content { display: none; position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
|
|
2374
|
+
.tab-content.active { display: block; }
|
|
2375
|
+
.tab-content.with-sidebar.active { display: flex; }
|
|
2376
|
+
.tab-content svg { animation: fadeInScale 0.4s ease-out; }
|
|
2377
|
+
@keyframes fadeInScale {
|
|
2378
|
+
from { opacity: 0; transform: scale(0.98); }
|
|
2379
|
+
to { opacity: 1; transform: scale(1); }
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
/* Layout with sidebar */
|
|
2383
|
+
.with-sidebar .viz-area { flex: 1; position: relative; height: 100%; }
|
|
2384
|
+
.with-sidebar .sidebar {
|
|
2385
|
+
width: 300px;
|
|
2386
|
+
height: 100%;
|
|
2387
|
+
background: rgba(22,33,62,0.95);
|
|
2388
|
+
backdrop-filter: blur(8px);
|
|
2389
|
+
-webkit-backdrop-filter: blur(8px);
|
|
2390
|
+
border-left: 1px solid var(--border);
|
|
2391
|
+
box-shadow: -4px 0 16px rgba(0,0,0,0.3);
|
|
2392
|
+
overflow-y: auto;
|
|
2393
|
+
padding: 16px;
|
|
2394
|
+
}
|
|
2395
|
+
.sidebar h3 { font-size: 14px; margin-bottom: 12px; color: var(--accent); text-shadow: 0 0 10px rgba(233,69,96,0.3); }
|
|
2396
|
+
.sidebar .risk-file, .sidebar .hotspot-item {
|
|
2397
|
+
padding: 8px 10px;
|
|
2398
|
+
border-bottom: 1px solid var(--border);
|
|
2399
|
+
font-size: 12px;
|
|
2400
|
+
border-radius: 4px;
|
|
2401
|
+
margin-bottom: 2px;
|
|
2402
|
+
transition: all var(--transition-fast);
|
|
2403
|
+
}
|
|
2404
|
+
.sidebar .risk-file:hover, .sidebar .hotspot-item:hover {
|
|
2405
|
+
background: rgba(94,173,247,0.06);
|
|
2406
|
+
border-left: 3px solid var(--accent);
|
|
2407
|
+
padding-left: 13px;
|
|
2408
|
+
transform: translateX(2px);
|
|
2409
|
+
}
|
|
2410
|
+
.sidebar .path { color: var(--text-primary); word-break: break-all; }
|
|
2411
|
+
.sidebar .meta { color: var(--text-dim); margin-top: 2px; }
|
|
2412
|
+
.risk-badge {
|
|
2413
|
+
display: inline-block;
|
|
2414
|
+
padding: 2px 8px;
|
|
2415
|
+
border-radius: 10px;
|
|
2416
|
+
font-size: 10px;
|
|
2417
|
+
font-weight: bold;
|
|
2418
|
+
margin-left: 4px;
|
|
2419
|
+
box-shadow: var(--shadow-sm);
|
|
2420
|
+
}
|
|
2421
|
+
.risk-critical { background: var(--color-critical); color: white; box-shadow: 0 0 8px rgba(233,69,96,0.4); }
|
|
2422
|
+
.risk-high { background: var(--color-high); color: white; box-shadow: 0 0 6px rgba(240,112,64,0.4); }
|
|
2423
|
+
.risk-medium { background: var(--color-medium); color: black; }
|
|
2424
|
+
.risk-low { background: var(--color-safe); color: white; }
|
|
2425
|
+
|
|
2426
|
+
/* Multi-user controls */
|
|
2427
|
+
#multiuser-controls {
|
|
2428
|
+
display: none;
|
|
2429
|
+
padding: 8px 24px;
|
|
2430
|
+
background: var(--bg-panel);
|
|
2431
|
+
border-bottom: 1px solid var(--border);
|
|
2432
|
+
align-items: center;
|
|
2433
|
+
gap: 12px;
|
|
2434
|
+
}
|
|
2435
|
+
#multiuser-controls.visible { display: flex; }
|
|
2436
|
+
#multiuser-controls select {
|
|
2437
|
+
padding: 6px 14px;
|
|
2438
|
+
border: 1px solid var(--border);
|
|
2439
|
+
background: var(--bg-base);
|
|
2440
|
+
color: var(--text-primary);
|
|
2441
|
+
border-radius: 6px;
|
|
2442
|
+
font-size: 13px;
|
|
2443
|
+
box-shadow: var(--shadow-sm);
|
|
2444
|
+
transition: all var(--transition-fast);
|
|
2445
|
+
cursor: pointer;
|
|
2446
|
+
}
|
|
2447
|
+
#multiuser-controls select:hover { border-color: var(--link); box-shadow: 0 0 8px rgba(94,173,247,0.2); }
|
|
2448
|
+
#multiuser-controls select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 12px rgba(233,69,96,0.3); }
|
|
2449
|
+
#multiuser-controls label { font-size: 13px; color: var(--text-dim); }
|
|
2450
|
+
|
|
2451
|
+
/* Hotspot controls */
|
|
2452
|
+
#hotspot-controls {
|
|
2453
|
+
display: none;
|
|
2454
|
+
padding: 8px 24px;
|
|
2455
|
+
background: var(--bg-panel);
|
|
2456
|
+
border-bottom: 1px solid var(--border);
|
|
2457
|
+
align-items: center;
|
|
2458
|
+
gap: 12px;
|
|
2459
|
+
}
|
|
2460
|
+
#hotspot-controls.visible { display: flex; }
|
|
2461
|
+
#hotspot-controls label { font-size: 13px; color: var(--text-dim); }
|
|
2462
|
+
#hotspot-controls .sep-v {
|
|
2463
|
+
width: 1px;
|
|
2464
|
+
height: 20px;
|
|
2465
|
+
background: var(--border);
|
|
2466
|
+
margin: 0 8px;
|
|
2467
|
+
}
|
|
2468
|
+
.subtab.disabled {
|
|
2469
|
+
opacity: 0.35;
|
|
2470
|
+
pointer-events: none;
|
|
2471
|
+
cursor: default;
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
/* Tooltip */
|
|
2475
|
+
#tooltip {
|
|
2476
|
+
position: absolute;
|
|
2477
|
+
pointer-events: none;
|
|
2478
|
+
background: var(--glass-bg);
|
|
2479
|
+
backdrop-filter: blur(12px);
|
|
2480
|
+
-webkit-backdrop-filter: blur(12px);
|
|
2481
|
+
border: 1px solid var(--glass-border);
|
|
2482
|
+
border-radius: 8px;
|
|
2483
|
+
padding: 12px 16px;
|
|
2484
|
+
font-size: 13px;
|
|
2485
|
+
line-height: 1.6;
|
|
2486
|
+
display: none;
|
|
2487
|
+
z-index: 100;
|
|
2488
|
+
max-width: 350px;
|
|
2489
|
+
box-shadow: var(--shadow-lg);
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
/* Legends */
|
|
2493
|
+
.legend {
|
|
2494
|
+
position: absolute;
|
|
2495
|
+
bottom: 16px;
|
|
2496
|
+
right: 16px;
|
|
2497
|
+
background: var(--glass-bg);
|
|
2498
|
+
backdrop-filter: blur(10px);
|
|
2499
|
+
-webkit-backdrop-filter: blur(10px);
|
|
2500
|
+
border: 1px solid var(--glass-border);
|
|
2501
|
+
border-radius: 10px;
|
|
2502
|
+
padding: 12px 14px;
|
|
2503
|
+
font-size: 12px;
|
|
2504
|
+
display: none;
|
|
2505
|
+
z-index: 50;
|
|
2506
|
+
box-shadow: var(--shadow-md);
|
|
2507
|
+
}
|
|
2508
|
+
.legend.active { display: block; }
|
|
2509
|
+
.legend .gradient-bar {
|
|
2510
|
+
width: 120px;
|
|
2511
|
+
height: 12px;
|
|
2512
|
+
background: linear-gradient(to right, var(--color-critical), var(--color-medium), var(--color-safe));
|
|
2513
|
+
border-radius: 6px;
|
|
2514
|
+
margin: 4px 0;
|
|
2515
|
+
box-shadow: inset 0 1px 2px rgba(0,0,0,0.2);
|
|
2516
|
+
}
|
|
2517
|
+
.legend .labels { display: flex; justify-content: space-between; font-size: 10px; color: var(--text-dim); }
|
|
2518
|
+
.legend .row { display: flex; align-items: center; gap: 6px; margin: 3px 0; }
|
|
2519
|
+
.legend .swatch { width: 14px; height: 14px; border-radius: 4px; box-shadow: var(--shadow-sm); }
|
|
2520
|
+
|
|
2521
|
+
/* Zone labels for hotspot */
|
|
2522
|
+
#zone-labels { position: absolute; pointer-events: none; }
|
|
2523
|
+
.zone-label {
|
|
2524
|
+
position: absolute;
|
|
2525
|
+
font-size: 16px;
|
|
2526
|
+
font-weight: bold;
|
|
2527
|
+
}
|
|
2528
|
+
</style>
|
|
2529
|
+
</head>
|
|
2530
|
+
<body>
|
|
2531
|
+
<div id="header">
|
|
2532
|
+
<h1>GitFamiliar \u2014 ${data.repoName}</h1>
|
|
2533
|
+
<div class="info">${data.userName} | ${data.scoring.binary.totalFiles} files</div>
|
|
2534
|
+
</div>
|
|
2535
|
+
|
|
2536
|
+
<div id="tabs">
|
|
2537
|
+
<div class="tab active" onclick="switchTab('scoring')">Scoring</div>
|
|
2538
|
+
<div class="tab" onclick="switchTab('coverage')">Coverage</div>
|
|
2539
|
+
<div class="tab" onclick="switchTab('multiuser')">Multi-User</div>
|
|
2540
|
+
<div class="tab" onclick="switchTab('hotspots')">Hotspots</div>
|
|
2541
|
+
</div>
|
|
2542
|
+
|
|
2543
|
+
<div id="tab-desc-scoring" class="tab-desc visible">
|
|
2544
|
+
Your personal familiarity with each file, based on Git history. Larger blocks = more lines of code. Color shows how well you know each file.
|
|
2545
|
+
</div>
|
|
2546
|
+
<div id="tab-desc-coverage" class="tab-desc">
|
|
2547
|
+
Team knowledge distribution: how many people have contributed to each file. Low contributor count = high bus factor risk.
|
|
2548
|
+
</div>
|
|
2549
|
+
<div id="tab-desc-multiuser" class="tab-desc">
|
|
2550
|
+
Compare familiarity scores across team members. Select a user to see the codebase colored by their knowledge.
|
|
2551
|
+
</div>
|
|
2552
|
+
<div id="tab-desc-hotspots" class="tab-desc">
|
|
2553
|
+
Files that change frequently but are poorly understood. Top-left = danger zone (high change, low familiarity).
|
|
2554
|
+
</div>
|
|
2555
|
+
|
|
2556
|
+
<div id="scoring-controls" class="visible">
|
|
2557
|
+
<button class="subtab active" onclick="switchScoringMode('binary')">Binary</button>
|
|
2558
|
+
<button class="subtab" onclick="switchScoringMode('authorship')">Authorship</button>
|
|
2559
|
+
<button class="subtab" onclick="switchScoringMode('weighted')">Weighted</button>
|
|
2560
|
+
<div id="weight-controls">
|
|
2561
|
+
<span>Blame:</span>
|
|
2562
|
+
<span class="weight-label" id="blame-label">50%</span>
|
|
2563
|
+
<input type="range" id="blame-slider" min="0" max="100" value="50" oninput="onWeightChange()">
|
|
2564
|
+
<span>Commit:</span>
|
|
2565
|
+
<span class="weight-label" id="commit-label">50%</span>
|
|
2566
|
+
</div>
|
|
2567
|
+
</div>
|
|
2568
|
+
<div id="scoring-mode-desc" class="tab-desc visible" style="padding-top:0">
|
|
2569
|
+
<span id="mode-desc-text">Binary: Have you ever committed to this file? Yes (green) or No (red).</span>
|
|
2570
|
+
</div>
|
|
2571
|
+
|
|
2572
|
+
<div id="multiuser-controls">
|
|
2573
|
+
<label>View as:</label>
|
|
2574
|
+
<select id="userSelect" onchange="onUserChange()"></select>
|
|
2575
|
+
</div>
|
|
2576
|
+
|
|
2577
|
+
<div id="hotspot-controls">
|
|
2578
|
+
<label>Mode:</label>
|
|
2579
|
+
<button class="subtab active" onclick="switchHotspotMode('personal')">Personal</button>
|
|
2580
|
+
<button class="subtab" onclick="switchHotspotMode('team')">Team</button>
|
|
2581
|
+
<span class="sep-v"></span>
|
|
2582
|
+
<label>Scoring:</label>
|
|
2583
|
+
<button class="subtab hs-scoring active" onclick="switchHotspotScoring('binary')">Binary</button>
|
|
2584
|
+
<button class="subtab hs-scoring" onclick="switchHotspotScoring('authorship')">Authorship</button>
|
|
2585
|
+
<button class="subtab hs-scoring" onclick="switchHotspotScoring('weighted')">Weighted</button>
|
|
2586
|
+
</div>
|
|
2587
|
+
|
|
2588
|
+
<div id="breadcrumb"><span onclick="zoomTo('')">root</span></div>
|
|
2589
|
+
|
|
2590
|
+
<div id="content-area">
|
|
2591
|
+
<div id="tab-scoring" class="tab-content active"></div>
|
|
2592
|
+
<div id="tab-coverage" class="tab-content with-sidebar">
|
|
2593
|
+
<div class="viz-area" id="coverage-viz"></div>
|
|
2594
|
+
<div class="sidebar" id="coverage-sidebar">
|
|
2595
|
+
<h3>Risk Files (0-1 contributors)</h3>
|
|
2596
|
+
<div id="risk-list"></div>
|
|
2597
|
+
</div>
|
|
2598
|
+
</div>
|
|
2599
|
+
<div id="tab-multiuser" class="tab-content"></div>
|
|
2600
|
+
<div id="tab-hotspots" class="tab-content with-sidebar">
|
|
2601
|
+
<div class="viz-area" id="hotspot-viz">
|
|
2602
|
+
<div id="zone-labels"></div>
|
|
2603
|
+
</div>
|
|
2604
|
+
<div class="sidebar" id="hotspot-sidebar">
|
|
2605
|
+
<h3>Top Hotspots</h3>
|
|
2606
|
+
<div id="hotspot-list"></div>
|
|
2607
|
+
</div>
|
|
2608
|
+
</div>
|
|
2609
|
+
</div>
|
|
2610
|
+
|
|
2611
|
+
<div id="tooltip"></div>
|
|
2612
|
+
|
|
2613
|
+
<!-- Legends -->
|
|
2614
|
+
<div class="legend active" id="legend-scoring">
|
|
2615
|
+
<div>Familiarity</div>
|
|
2616
|
+
<div class="gradient-bar"></div>
|
|
2617
|
+
<div class="labels"><span>0%</span><span>50%</span><span>100%</span></div>
|
|
2618
|
+
</div>
|
|
2619
|
+
<div class="legend" id="legend-coverage">
|
|
2620
|
+
<div>Contributors</div>
|
|
2621
|
+
<div class="row"><div class="swatch" style="background:var(--color-critical)"></div> 0\u20131 (Risk)</div>
|
|
2622
|
+
<div class="row"><div class="swatch" style="background:var(--color-medium)"></div> 2\u20133 (Moderate)</div>
|
|
2623
|
+
<div class="row"><div class="swatch" style="background:var(--color-safe)"></div> 4+ (Safe)</div>
|
|
2624
|
+
</div>
|
|
2625
|
+
<div class="legend" id="legend-multiuser">
|
|
2626
|
+
<div>Familiarity</div>
|
|
2627
|
+
<div class="gradient-bar"></div>
|
|
2628
|
+
<div class="labels"><span>0%</span><span>50%</span><span>100%</span></div>
|
|
2629
|
+
</div>
|
|
2630
|
+
|
|
2631
|
+
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
2632
|
+
<script>
|
|
2633
|
+
// \u2500\u2500 Data \u2500\u2500
|
|
2634
|
+
const scoringData = {
|
|
2635
|
+
binary: ${scoringBinaryJson},
|
|
2636
|
+
authorship: ${scoringAuthorshipJson},
|
|
2637
|
+
weighted: ${scoringWeightedJson},
|
|
2638
|
+
};
|
|
2639
|
+
const coverageData = ${coverageTreeJson};
|
|
2640
|
+
const coverageRiskFiles = ${coverageRiskJson};
|
|
2641
|
+
const hotspotData = ${hotspotJson};
|
|
2642
|
+
const hotspotTeamFamiliarity = ${hotspotTeamFamJson};
|
|
2643
|
+
const multiUserData = ${multiUserTreeJson};
|
|
2644
|
+
const multiUserNames = ${multiUserNamesJson};
|
|
2645
|
+
const multiUserSummaries = ${multiUserSummariesJson};
|
|
2646
|
+
|
|
2647
|
+
// \u2500\u2500 State \u2500\u2500
|
|
2648
|
+
let activeTab = 'scoring';
|
|
2649
|
+
let scoringMode = 'binary';
|
|
2650
|
+
let blameWeight = 0.5;
|
|
2651
|
+
let scoringPath = '';
|
|
2652
|
+
let coveragePath = '';
|
|
2653
|
+
let multiuserPath = '';
|
|
2654
|
+
let currentUser = 0;
|
|
2655
|
+
let hotspotMode = 'personal';
|
|
2656
|
+
let hotspotScoring = 'binary';
|
|
2657
|
+
const rendered = { scoring: false, coverage: false, hotspots: false, multiuser: false };
|
|
2658
|
+
|
|
2659
|
+
// \u2500\u2500 Hotspot recalculation utilities \u2500\u2500
|
|
2660
|
+
function extractFlatScores(node) {
|
|
2661
|
+
const map = {};
|
|
2662
|
+
function walk(n) {
|
|
2663
|
+
if (n.type === 'file') { map[n.path] = n.score; }
|
|
2664
|
+
else if (n.children) { n.children.forEach(walk); }
|
|
2665
|
+
}
|
|
2666
|
+
walk(node);
|
|
2667
|
+
return map;
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
const personalScores = {
|
|
2671
|
+
binary: extractFlatScores(scoringData.binary),
|
|
2672
|
+
authorship: extractFlatScores(scoringData.authorship),
|
|
2673
|
+
weighted: extractFlatScores(scoringData.weighted),
|
|
2674
|
+
};
|
|
2675
|
+
|
|
2676
|
+
function recalculateHotspotData() {
|
|
2677
|
+
const famScores = hotspotMode === 'personal'
|
|
2678
|
+
? personalScores[hotspotScoring]
|
|
2679
|
+
: hotspotTeamFamiliarity;
|
|
2680
|
+
const maxFreq = d3.max(hotspotData, d => d.changeFrequency) || 1;
|
|
2681
|
+
return hotspotData.map(d => {
|
|
2682
|
+
const familiarity = famScores[d.path] || 0;
|
|
2683
|
+
const normalizedFreq = d.changeFrequency / maxFreq;
|
|
2684
|
+
const risk = normalizedFreq * (1 - familiarity);
|
|
2685
|
+
return { ...d, familiarity, risk,
|
|
2686
|
+
riskLevel: risk >= 0.6 ? 'critical' : risk >= 0.4 ? 'high' : risk >= 0.2 ? 'medium' : 'low',
|
|
2687
|
+
};
|
|
2688
|
+
}).sort((a, b) => b.risk - a.risk);
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
function switchHotspotMode(mode) {
|
|
2692
|
+
hotspotMode = mode;
|
|
2693
|
+
document.querySelectorAll('#hotspot-controls .subtab:not(.hs-scoring)').forEach(el => {
|
|
2694
|
+
el.classList.toggle('active', el.textContent.toLowerCase() === mode);
|
|
2695
|
+
});
|
|
2696
|
+
// Disable scoring buttons in team mode
|
|
2697
|
+
const isTeam = mode === 'team';
|
|
2698
|
+
document.querySelectorAll('#hotspot-controls .hs-scoring').forEach(el => {
|
|
2699
|
+
el.classList.toggle('disabled', isTeam);
|
|
2700
|
+
});
|
|
2701
|
+
renderHotspot();
|
|
2702
|
+
renderHotspotSidebar();
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
function switchHotspotScoring(mode) {
|
|
2706
|
+
if (hotspotMode === 'team') return;
|
|
2707
|
+
hotspotScoring = mode;
|
|
2708
|
+
document.querySelectorAll('#hotspot-controls .hs-scoring').forEach(el => {
|
|
2709
|
+
el.classList.toggle('active', el.textContent.toLowerCase() === mode);
|
|
2710
|
+
});
|
|
2711
|
+
renderHotspot();
|
|
2712
|
+
renderHotspotSidebar();
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
// \u2500\u2500 Common utilities \u2500\u2500
|
|
2716
|
+
function scoreColor(score) {
|
|
2717
|
+
if (score <= 0) return '#e94560';
|
|
2718
|
+
if (score >= 1) return '#27ae60';
|
|
2719
|
+
if (score < 0.5) return d3.interpolateRgb('#e94560', '#f5a623')(score / 0.5);
|
|
2720
|
+
return d3.interpolateRgb('#f5a623', '#27ae60')((score - 0.5) / 0.5);
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
function coverageColor(count) {
|
|
2724
|
+
if (count <= 0) return '#e94560';
|
|
2725
|
+
if (count === 1) return '#d63c57';
|
|
2726
|
+
if (count <= 3) return '#f5a623';
|
|
2727
|
+
return '#27ae60';
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
function folderRiskColor(riskLevel) {
|
|
2731
|
+
switch (riskLevel) {
|
|
2732
|
+
case 'risk': return '#e94560';
|
|
2733
|
+
case 'moderate': return '#f5a623';
|
|
2734
|
+
default: return '#27ae60';
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
function riskLevelColor(level) {
|
|
2739
|
+
switch(level) {
|
|
2740
|
+
case 'critical': return '#e94560';
|
|
2741
|
+
case 'high': return '#f07040';
|
|
2742
|
+
case 'medium': return '#f5a623';
|
|
2743
|
+
default: return '#27ae60';
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
function findNode(node, path) {
|
|
2748
|
+
if (node.path === path) return node;
|
|
2749
|
+
if (node.children) {
|
|
2750
|
+
for (const child of node.children) {
|
|
2751
|
+
const found = findNode(child, path);
|
|
2752
|
+
if (found) return found;
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
return null;
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
function buildHierarchy(node) {
|
|
2759
|
+
if (node.type === 'file') {
|
|
2760
|
+
return { name: node.path.split('/').pop(), data: node, value: Math.max(1, node.lines) };
|
|
2761
|
+
}
|
|
2762
|
+
return {
|
|
2763
|
+
name: node.path.split('/').pop() || node.path,
|
|
2764
|
+
data: node,
|
|
2765
|
+
children: (node.children || []).map(c => buildHierarchy(c)),
|
|
2766
|
+
};
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
function updateBreadcrumb(path) {
|
|
2770
|
+
const el = document.getElementById('breadcrumb');
|
|
2771
|
+
const parts = path ? path.split('/') : [];
|
|
2772
|
+
let html = '<span onclick="zoomTo(\\'\\')">root</span>';
|
|
2773
|
+
let accumulated = '';
|
|
2774
|
+
for (const part of parts) {
|
|
2775
|
+
accumulated = accumulated ? accumulated + '/' + part : part;
|
|
2776
|
+
const p = accumulated;
|
|
2777
|
+
html += '<span class="sep">/</span><span onclick="zoomTo(\\'' + p + '\\')">' + part + '</span>';
|
|
2778
|
+
}
|
|
2779
|
+
el.innerHTML = html;
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
function showTooltipAt(html, event) {
|
|
2783
|
+
const tooltip = document.getElementById('tooltip');
|
|
2784
|
+
tooltip.innerHTML = html;
|
|
2785
|
+
tooltip.style.display = 'block';
|
|
2786
|
+
tooltip.style.left = (event.pageX + 14) + 'px';
|
|
2787
|
+
tooltip.style.top = (event.pageY - 14) + 'px';
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
function moveTooltip(event) {
|
|
2791
|
+
const tooltip = document.getElementById('tooltip');
|
|
2792
|
+
tooltip.style.left = (event.pageX + 14) + 'px';
|
|
2793
|
+
tooltip.style.top = (event.pageY - 14) + 'px';
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
function hideTooltip() {
|
|
2797
|
+
document.getElementById('tooltip').style.display = 'none';
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
function truncateLabel(name, w, h) {
|
|
2801
|
+
if (w < 36 || h < 18) return '';
|
|
2802
|
+
const maxChars = Math.floor((w - 8) / 6.5);
|
|
2803
|
+
if (name.length > maxChars) return name.slice(0, maxChars - 1) + '\\u2026';
|
|
2804
|
+
return name;
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
// \u2500\u2500 Tab switching \u2500\u2500
|
|
2808
|
+
const modeDescriptions = {
|
|
2809
|
+
binary: 'Binary: Have you ever committed to this file? Yes (green) or No (red).',
|
|
2810
|
+
authorship: 'Authorship: How much of the current code did you write? Based on git blame line ownership.',
|
|
2811
|
+
weighted: 'Weighted: Combines blame ownership and commit history with adjustable weights. Use the sliders to tune.',
|
|
2812
|
+
};
|
|
2813
|
+
|
|
2814
|
+
function switchTab(tab) {
|
|
2815
|
+
activeTab = tab;
|
|
2816
|
+
document.querySelectorAll('#tabs .tab').forEach((el, i) => {
|
|
2817
|
+
const tabs = ['scoring', 'coverage', 'multiuser', 'hotspots'];
|
|
2818
|
+
el.classList.toggle('active', tabs[i] === tab);
|
|
2819
|
+
});
|
|
2820
|
+
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
|
|
2821
|
+
document.getElementById('tab-' + tab).classList.add('active');
|
|
2822
|
+
|
|
2823
|
+
// Show/hide tab descriptions
|
|
2824
|
+
['scoring', 'coverage', 'multiuser', 'hotspots'].forEach(t => {
|
|
2825
|
+
document.getElementById('tab-desc-' + t).classList.toggle('visible', t === tab);
|
|
2826
|
+
});
|
|
2827
|
+
|
|
2828
|
+
// Show/hide controls
|
|
2829
|
+
document.getElementById('scoring-controls').classList.toggle('visible', tab === 'scoring');
|
|
2830
|
+
document.getElementById('scoring-mode-desc').classList.toggle('visible', tab === 'scoring');
|
|
2831
|
+
document.getElementById('multiuser-controls').classList.toggle('visible', tab === 'multiuser');
|
|
2832
|
+
document.getElementById('hotspot-controls').classList.toggle('visible', tab === 'hotspots');
|
|
2833
|
+
|
|
2834
|
+
// Show/hide breadcrumb
|
|
2835
|
+
const showBreadcrumb = tab === 'scoring' || tab === 'coverage' || tab === 'multiuser';
|
|
2836
|
+
document.getElementById('breadcrumb').classList.toggle('visible', showBreadcrumb);
|
|
2837
|
+
|
|
2838
|
+
// Show/hide legends
|
|
2839
|
+
document.getElementById('legend-scoring').classList.toggle('active', tab === 'scoring');
|
|
2840
|
+
document.getElementById('legend-coverage').classList.toggle('active', tab === 'coverage');
|
|
2841
|
+
document.getElementById('legend-multiuser').classList.toggle('active', tab === 'multiuser');
|
|
2842
|
+
|
|
2843
|
+
// Update breadcrumb for current tab
|
|
2844
|
+
if (tab === 'scoring') updateBreadcrumb(scoringPath);
|
|
2845
|
+
else if (tab === 'coverage') updateBreadcrumb(coveragePath);
|
|
2846
|
+
else if (tab === 'multiuser') updateBreadcrumb(multiuserPath);
|
|
2847
|
+
|
|
2848
|
+
// Render after a short delay so layout is computed after display change
|
|
2849
|
+
setTimeout(() => {
|
|
2850
|
+
if (!rendered[tab]) {
|
|
2851
|
+
rendered[tab] = true;
|
|
2852
|
+
if (tab === 'coverage') { renderCoverageSidebar(); renderCoverage(); }
|
|
2853
|
+
else if (tab === 'hotspots') { renderHotspotSidebar(); renderHotspot(); }
|
|
2854
|
+
else if (tab === 'multiuser') { initMultiUserSelect(); renderMultiUser(); }
|
|
2855
|
+
} else {
|
|
2856
|
+
if (tab === 'scoring') renderScoring();
|
|
2857
|
+
else if (tab === 'coverage') renderCoverage();
|
|
2858
|
+
else if (tab === 'hotspots') renderHotspot();
|
|
2859
|
+
else if (tab === 'multiuser') renderMultiUser();
|
|
2860
|
+
}
|
|
2861
|
+
}, 0);
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
// \u2500\u2500 Zoom (shared across treemap tabs) \u2500\u2500
|
|
2865
|
+
function zoomTo(path) {
|
|
2866
|
+
if (activeTab === 'scoring') { scoringPath = path; renderScoring(); }
|
|
2867
|
+
else if (activeTab === 'coverage') { coveragePath = path; renderCoverage(); }
|
|
2868
|
+
else if (activeTab === 'multiuser') { multiuserPath = path; renderMultiUser(); }
|
|
2869
|
+
updateBreadcrumb(path);
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
// \u2500\u2500 Layout dimensions \u2500\u2500
|
|
2873
|
+
function getContentHeight() {
|
|
2874
|
+
return document.getElementById('content-area').offsetHeight;
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
// \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
|
|
2878
|
+
// \u2500\u2500 SCORING TAB \u2500\u2500
|
|
2879
|
+
// \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
|
|
2880
|
+
|
|
2881
|
+
function switchScoringMode(mode) {
|
|
2882
|
+
scoringMode = mode;
|
|
2883
|
+
scoringPath = '';
|
|
2884
|
+
updateBreadcrumb('');
|
|
2885
|
+
document.querySelectorAll('#scoring-controls .subtab').forEach(el => {
|
|
2886
|
+
el.classList.toggle('active', el.textContent.toLowerCase() === mode);
|
|
2887
|
+
});
|
|
2888
|
+
document.getElementById('weight-controls').classList.toggle('visible', mode === 'weighted');
|
|
2889
|
+
document.getElementById('mode-desc-text').textContent = modeDescriptions[mode];
|
|
2890
|
+
renderScoring();
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
function onWeightChange() {
|
|
2894
|
+
const slider = document.getElementById('blame-slider');
|
|
2895
|
+
const bv = parseInt(slider.value);
|
|
2896
|
+
blameWeight = bv / 100;
|
|
2897
|
+
document.getElementById('blame-label').textContent = bv + '%';
|
|
2898
|
+
document.getElementById('commit-label').textContent = (100 - bv) + '%';
|
|
2899
|
+
recalcWeightedScores(scoringData.weighted, blameWeight, 1 - blameWeight);
|
|
2900
|
+
renderScoring();
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
function recalcWeightedScores(node, bw, cw) {
|
|
2904
|
+
if (node.type === 'file') {
|
|
2905
|
+
const bs = node.blameScore || 0;
|
|
2906
|
+
const cs = node.commitScore || 0;
|
|
2907
|
+
node.score = bw * bs + cw * cs;
|
|
2908
|
+
} else if (node.children) {
|
|
2909
|
+
let totalLines = 0;
|
|
2910
|
+
let weightedSum = 0;
|
|
2911
|
+
for (const child of node.children) {
|
|
2912
|
+
recalcWeightedScores(child, bw, cw);
|
|
2913
|
+
const lines = child.lines || 1;
|
|
2914
|
+
totalLines += lines;
|
|
2915
|
+
weightedSum += child.score * lines;
|
|
2916
|
+
}
|
|
2917
|
+
node.score = totalLines > 0 ? weightedSum / totalLines : 0;
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
function renderScoring() {
|
|
2922
|
+
const container = document.getElementById('tab-scoring');
|
|
2923
|
+
container.innerHTML = '';
|
|
2924
|
+
const height = getContentHeight();
|
|
2925
|
+
const width = window.innerWidth;
|
|
2926
|
+
|
|
2927
|
+
const treeData = scoringData[scoringMode];
|
|
2928
|
+
const targetNode = scoringPath ? findNode(treeData, scoringPath) : treeData;
|
|
2929
|
+
if (!targetNode || !targetNode.children || targetNode.children.length === 0) return;
|
|
2930
|
+
|
|
2931
|
+
const hierarchyData = {
|
|
2932
|
+
name: targetNode.path || 'root',
|
|
2933
|
+
children: targetNode.children.map(c => buildHierarchy(c)),
|
|
2934
|
+
};
|
|
2935
|
+
|
|
2936
|
+
const root = d3.hierarchy(hierarchyData)
|
|
2937
|
+
.sum(d => d.value || 0)
|
|
2938
|
+
.sort((a, b) => (b.value || 0) - (a.value || 0));
|
|
2939
|
+
|
|
2940
|
+
d3.treemap().size([width, height]).padding(2).paddingTop(20).round(true)(root);
|
|
2941
|
+
|
|
2942
|
+
const svg = d3.select('#tab-scoring').append('svg').attr('width', width).attr('height', height);
|
|
2943
|
+
const nodes = root.descendants().filter(d => d.depth > 0);
|
|
2944
|
+
|
|
2945
|
+
const groups = svg.selectAll('g').data(nodes).join('g')
|
|
2946
|
+
.attr('transform', d => 'translate(' + d.x0 + ',' + d.y0 + ')');
|
|
2947
|
+
|
|
2948
|
+
groups.append('rect')
|
|
2949
|
+
.attr('width', d => Math.max(0, d.x1 - d.x0))
|
|
2950
|
+
.attr('height', d => Math.max(0, d.y1 - d.y0))
|
|
2951
|
+
.attr('fill', d => d.data.data ? scoreColor(d.data.data.score) : '#333')
|
|
2952
|
+
.attr('opacity', d => d.children ? 0.35 : 0.88)
|
|
2953
|
+
.attr('stroke', '#1a1a2e').attr('stroke-width', d => d.children ? 1 : 0.5).attr('rx', 2)
|
|
2954
|
+
.style('cursor', d => (d.data.data && d.data.data.type === 'folder') ? 'pointer' : 'default')
|
|
2955
|
+
.on('click', (event, d) => {
|
|
2956
|
+
if (d.data.data && d.data.data.type === 'folder') { event.stopPropagation(); zoomTo(d.data.data.path); }
|
|
2957
|
+
})
|
|
2958
|
+
.on('mouseover', function(event, d) {
|
|
2959
|
+
if (!d.data.data) return;
|
|
2960
|
+
d3.select(this).attr('opacity', d.children ? 0.5 : 1).attr('stroke', '#fff');
|
|
2961
|
+
let html = '<strong>' + d.data.data.path + '</strong>';
|
|
2962
|
+
html += '<br>Score: ' + Math.round(d.data.data.score * 100) + '%';
|
|
2963
|
+
html += '<br>Lines: ' + d.data.data.lines.toLocaleString();
|
|
2964
|
+
if (d.data.data.type === 'folder') {
|
|
2965
|
+
html += '<br>Files: ' + d.data.data.fileCount;
|
|
2966
|
+
html += '<br><em style="color:#5eadf7">Click to drill down \\u25B6</em>';
|
|
2967
|
+
}
|
|
2968
|
+
if (d.data.data.blameScore !== undefined) html += '<br>Blame: ' + Math.round(d.data.data.blameScore * 100) + '%';
|
|
2969
|
+
if (d.data.data.commitScore !== undefined) html += '<br>Commit: ' + Math.round(d.data.data.commitScore * 100) + '%';
|
|
2970
|
+
if (d.data.data.isExpired) html += '<br><span style="color:#e94560">Expired</span>';
|
|
2971
|
+
showTooltipAt(html, event);
|
|
2972
|
+
})
|
|
2973
|
+
.on('mousemove', moveTooltip)
|
|
2974
|
+
.on('mouseout', function(event, d) {
|
|
2975
|
+
d3.select(this).attr('opacity', d.children ? 0.35 : 0.88).attr('stroke', '#1a1a2e');
|
|
2976
|
+
hideTooltip();
|
|
2977
|
+
});
|
|
2978
|
+
|
|
2979
|
+
groups.append('text')
|
|
2980
|
+
.attr('x', 4).attr('y', 14).attr('fill', '#fff')
|
|
2981
|
+
.attr('font-size', d => d.children ? '11px' : '10px')
|
|
2982
|
+
.attr('font-weight', d => d.children ? 'bold' : 'normal')
|
|
2983
|
+
.style('pointer-events', 'none')
|
|
2984
|
+
.text(d => truncateLabel(d.data.name || '', d.x1 - d.x0, d.y1 - d.y0));
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
// \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
|
|
2988
|
+
// \u2500\u2500 COVERAGE TAB \u2500\u2500
|
|
2989
|
+
// \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
|
|
2990
|
+
|
|
2991
|
+
function renderCoverage() {
|
|
2992
|
+
const vizArea = document.getElementById('coverage-viz');
|
|
2993
|
+
vizArea.innerHTML = '';
|
|
2994
|
+
let height = vizArea.offsetHeight;
|
|
2995
|
+
let width = vizArea.offsetWidth;
|
|
2996
|
+
|
|
2997
|
+
if (!width || !height) {
|
|
2998
|
+
const contentH = document.getElementById('content-area').offsetHeight;
|
|
2999
|
+
width = window.innerWidth - 300;
|
|
3000
|
+
height = contentH;
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
const targetNode = coveragePath ? findNode(coverageData, coveragePath) : coverageData;
|
|
3004
|
+
if (!targetNode || !targetNode.children || targetNode.children.length === 0) return;
|
|
3005
|
+
|
|
3006
|
+
const hierarchyData = {
|
|
3007
|
+
name: targetNode.path || 'root',
|
|
3008
|
+
children: targetNode.children.map(c => buildHierarchy(c)),
|
|
3009
|
+
};
|
|
3010
|
+
|
|
3011
|
+
const root = d3.hierarchy(hierarchyData)
|
|
3012
|
+
.sum(d => d.value || 0)
|
|
3013
|
+
.sort((a, b) => (b.value || 0) - (a.value || 0));
|
|
3014
|
+
|
|
3015
|
+
d3.treemap().size([width, height]).padding(2).paddingTop(20).round(true)(root);
|
|
3016
|
+
|
|
3017
|
+
const svg = d3.select('#coverage-viz').append('svg').attr('width', width).attr('height', height);
|
|
3018
|
+
const nodes = root.descendants().filter(d => d.depth > 0);
|
|
3019
|
+
|
|
3020
|
+
const groups = svg.selectAll('g').data(nodes).join('g')
|
|
3021
|
+
.attr('transform', d => 'translate(' + d.x0 + ',' + d.y0 + ')');
|
|
3022
|
+
|
|
3023
|
+
groups.append('rect')
|
|
3024
|
+
.attr('width', d => Math.max(0, d.x1 - d.x0))
|
|
3025
|
+
.attr('height', d => Math.max(0, d.y1 - d.y0))
|
|
3026
|
+
.attr('fill', d => {
|
|
3027
|
+
if (!d.data.data) return '#333';
|
|
3028
|
+
if (d.data.data.type === 'file') return coverageColor(d.data.data.contributorCount);
|
|
3029
|
+
return folderRiskColor(d.data.data.riskLevel);
|
|
3030
|
+
})
|
|
3031
|
+
.attr('opacity', d => d.children ? 0.35 : 0.88)
|
|
3032
|
+
.attr('stroke', '#1a1a2e').attr('stroke-width', d => d.children ? 1 : 0.5).attr('rx', 2)
|
|
3033
|
+
.style('cursor', d => (d.data.data && d.data.data.type === 'folder') ? 'pointer' : 'default')
|
|
3034
|
+
.on('click', (event, d) => {
|
|
3035
|
+
if (d.data.data && d.data.data.type === 'folder') { event.stopPropagation(); zoomTo(d.data.data.path); }
|
|
3036
|
+
})
|
|
3037
|
+
.on('mouseover', function(event, d) {
|
|
3038
|
+
if (!d.data.data) return;
|
|
3039
|
+
d3.select(this).attr('opacity', d.children ? 0.5 : 1).attr('stroke', '#fff');
|
|
3040
|
+
let html = '<strong>' + d.data.data.path + '</strong>';
|
|
3041
|
+
if (d.data.data.type === 'file') {
|
|
3042
|
+
html += '<br>Contributors: ' + d.data.data.contributorCount;
|
|
3043
|
+
if (d.data.data.contributors && d.data.data.contributors.length > 0) {
|
|
3044
|
+
html += '<br>' + d.data.data.contributors.slice(0, 8).join(', ');
|
|
3045
|
+
if (d.data.data.contributors.length > 8) html += ', ...';
|
|
3046
|
+
}
|
|
3047
|
+
html += '<br>Lines: ' + d.data.data.lines.toLocaleString();
|
|
3048
|
+
} else {
|
|
3049
|
+
html += '<br>Files: ' + d.data.data.fileCount;
|
|
3050
|
+
html += '<br>Avg Contributors: ' + d.data.data.avgContributors;
|
|
3051
|
+
html += '<br>Bus Factor: ' + d.data.data.busFactor;
|
|
3052
|
+
html += '<br><em style="color:#5eadf7">Click to drill down \\u25B6</em>';
|
|
3053
|
+
}
|
|
3054
|
+
showTooltipAt(html, event);
|
|
3055
|
+
})
|
|
3056
|
+
.on('mousemove', moveTooltip)
|
|
3057
|
+
.on('mouseout', function(event, d) {
|
|
3058
|
+
d3.select(this).attr('opacity', d.children ? 0.35 : 0.88).attr('stroke', '#1a1a2e');
|
|
3059
|
+
hideTooltip();
|
|
3060
|
+
});
|
|
3061
|
+
|
|
3062
|
+
groups.append('text')
|
|
3063
|
+
.attr('x', 4).attr('y', 14).attr('fill', '#fff')
|
|
3064
|
+
.attr('font-size', d => d.children ? '11px' : '10px')
|
|
3065
|
+
.attr('font-weight', d => d.children ? 'bold' : 'normal')
|
|
3066
|
+
.style('pointer-events', 'none')
|
|
3067
|
+
.text(d => truncateLabel(d.data.name || '', d.x1 - d.x0, d.y1 - d.y0));
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
function renderCoverageSidebar() {
|
|
3071
|
+
const container = document.getElementById('risk-list');
|
|
3072
|
+
if (coverageRiskFiles.length === 0) {
|
|
3073
|
+
container.innerHTML = '<div style="color:#888">No high-risk files found.</div>';
|
|
3074
|
+
return;
|
|
3075
|
+
}
|
|
3076
|
+
let html = '';
|
|
3077
|
+
for (const f of coverageRiskFiles.slice(0, 50)) {
|
|
3078
|
+
const countLabel = f.contributorCount === 0 ? '0 people' : '1 person (' + f.contributors[0] + ')';
|
|
3079
|
+
html += '<div class="risk-file"><div class="path">' + f.path + '</div><div class="meta">' + countLabel + '</div></div>';
|
|
3080
|
+
}
|
|
3081
|
+
if (coverageRiskFiles.length > 50) {
|
|
3082
|
+
html += '<div style="color:#888;padding:8px 0">... and ' + (coverageRiskFiles.length - 50) + ' more</div>';
|
|
3083
|
+
}
|
|
3084
|
+
container.innerHTML = html;
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
// \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
|
|
3088
|
+
// \u2500\u2500 HOTSPOT TAB \u2500\u2500
|
|
3089
|
+
// \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
|
|
3090
|
+
|
|
3091
|
+
function renderHotspot() {
|
|
3092
|
+
const vizArea = document.getElementById('hotspot-viz');
|
|
3093
|
+
const existingSvg = vizArea.querySelector('svg');
|
|
3094
|
+
if (existingSvg) existingSvg.remove();
|
|
3095
|
+
|
|
3096
|
+
let height = vizArea.offsetHeight;
|
|
3097
|
+
let width = vizArea.offsetWidth;
|
|
3098
|
+
|
|
3099
|
+
// Fallback: if the element hasn't been laid out yet, calculate manually
|
|
3100
|
+
if (!width || !height) {
|
|
3101
|
+
const contentH = document.getElementById('content-area').offsetHeight;
|
|
3102
|
+
const totalW = window.innerWidth;
|
|
3103
|
+
width = totalW - 300; // subtract sidebar width
|
|
3104
|
+
height = contentH;
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
const currentData = recalculateHotspotData();
|
|
3108
|
+
|
|
3109
|
+
const margin = { top: 30, right: 30, bottom: 60, left: 70 };
|
|
3110
|
+
const innerW = width - margin.left - margin.right;
|
|
3111
|
+
const innerH = height - margin.top - margin.bottom;
|
|
3112
|
+
|
|
3113
|
+
const maxFreq = d3.max(currentData, d => d.changeFrequency) || 1;
|
|
3114
|
+
|
|
3115
|
+
const x = d3.scaleLinear().domain([0, 1]).range([0, innerW]);
|
|
3116
|
+
const y = d3.scaleLinear().domain([0, maxFreq * 1.1]).range([innerH, 0]);
|
|
3117
|
+
const r = d3.scaleSqrt().domain([0, d3.max(currentData, d => d.lines) || 1]).range([3, 20]);
|
|
3118
|
+
|
|
3119
|
+
const svg = d3.select('#hotspot-viz').append('svg').attr('width', width).attr('height', height);
|
|
3120
|
+
const g = svg.append('g').attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
|
|
3121
|
+
|
|
3122
|
+
// Danger zone
|
|
3123
|
+
g.append('rect').attr('x', 0).attr('y', 0)
|
|
3124
|
+
.attr('width', x(0.3)).attr('height', y(maxFreq * 0.3))
|
|
3125
|
+
.attr('fill', 'rgba(233, 69, 96, 0.06)');
|
|
3126
|
+
|
|
3127
|
+
// Axes
|
|
3128
|
+
g.append('g').attr('transform', 'translate(0,' + innerH + ')')
|
|
3129
|
+
.call(d3.axisBottom(x).ticks(5).tickFormat(d => Math.round(d * 100) + '%'))
|
|
3130
|
+
.selectAll('text,line,path').attr('stroke', '#555').attr('fill', '#888');
|
|
3131
|
+
|
|
3132
|
+
svg.append('text').attr('x', margin.left + innerW / 2).attr('y', height - 10)
|
|
3133
|
+
.attr('text-anchor', 'middle').attr('fill', '#888').attr('font-size', '13px')
|
|
3134
|
+
.text('Familiarity \\u2192');
|
|
3135
|
+
|
|
3136
|
+
g.append('g').call(d3.axisLeft(y).ticks(6))
|
|
3137
|
+
.selectAll('text,line,path').attr('stroke', '#555').attr('fill', '#888');
|
|
3138
|
+
|
|
3139
|
+
svg.append('text').attr('transform', 'rotate(-90)')
|
|
3140
|
+
.attr('x', -(margin.top + innerH / 2)).attr('y', 16)
|
|
3141
|
+
.attr('text-anchor', 'middle').attr('fill', '#888').attr('font-size', '13px')
|
|
3142
|
+
.text('Change Frequency (commits) \\u2192');
|
|
3143
|
+
|
|
3144
|
+
// Zone labels
|
|
3145
|
+
const labels = document.getElementById('zone-labels');
|
|
3146
|
+
labels.innerHTML = '';
|
|
3147
|
+
const dangerLabel = document.createElement('div');
|
|
3148
|
+
dangerLabel.className = 'zone-label';
|
|
3149
|
+
dangerLabel.style.left = (margin.left + 8) + 'px';
|
|
3150
|
+
dangerLabel.style.top = (margin.top + 8) + 'px';
|
|
3151
|
+
dangerLabel.textContent = 'DANGER ZONE';
|
|
3152
|
+
dangerLabel.style.color = 'rgba(233,69,96,0.25)';
|
|
3153
|
+
labels.appendChild(dangerLabel);
|
|
3154
|
+
|
|
3155
|
+
const safeLabel = document.createElement('div');
|
|
3156
|
+
safeLabel.className = 'zone-label';
|
|
3157
|
+
safeLabel.style.right = (320 + 40) + 'px';
|
|
3158
|
+
safeLabel.style.bottom = (margin.bottom + 16) + 'px';
|
|
3159
|
+
safeLabel.textContent = 'SAFE ZONE';
|
|
3160
|
+
safeLabel.style.color = 'rgba(39,174,96,0.2)';
|
|
3161
|
+
labels.appendChild(safeLabel);
|
|
3162
|
+
|
|
3163
|
+
// Data points
|
|
3164
|
+
g.selectAll('circle').data(currentData).join('circle')
|
|
3165
|
+
.attr('cx', d => x(d.familiarity))
|
|
3166
|
+
.attr('cy', d => y(d.changeFrequency))
|
|
3167
|
+
.attr('r', d => r(d.lines))
|
|
3168
|
+
.attr('fill', d => riskLevelColor(d.riskLevel))
|
|
3169
|
+
.attr('opacity', 0.7)
|
|
3170
|
+
.attr('stroke', 'none')
|
|
3171
|
+
.style('cursor', 'pointer')
|
|
3172
|
+
.style('filter', d => d.riskLevel === 'critical' ? 'drop-shadow(0 0 6px rgba(233,69,96,0.8))' : 'none')
|
|
3173
|
+
.style('transition', 'all 0.2s ease')
|
|
3174
|
+
.on('mouseover', function(event, d) {
|
|
3175
|
+
d3.select(this).attr('opacity', 1).attr('stroke', '#fff').attr('stroke-width', 2).style('filter', 'drop-shadow(0 0 12px rgba(255,255,255,0.5))');
|
|
3176
|
+
showTooltipAt(
|
|
3177
|
+
'<strong>' + d.path + '</strong>' +
|
|
3178
|
+
'<br>Familiarity: ' + Math.round(d.familiarity * 100) + '%' +
|
|
3179
|
+
'<br>Changes: ' + d.changeFrequency + ' commits' +
|
|
3180
|
+
'<br>Risk: ' + d.risk.toFixed(2) + ' (' + d.riskLevel + ')' +
|
|
3181
|
+
'<br>Lines: ' + d.lines.toLocaleString(),
|
|
3182
|
+
event
|
|
3183
|
+
);
|
|
3184
|
+
})
|
|
3185
|
+
.on('mousemove', moveTooltip)
|
|
3186
|
+
.on('mouseout', function(event, d) {
|
|
3187
|
+
const origFilter = d.riskLevel === 'critical' ? 'drop-shadow(0 0 6px rgba(233,69,96,0.8))' : 'none';
|
|
3188
|
+
d3.select(this).attr('opacity', 0.7).attr('stroke', 'none').style('filter', origFilter);
|
|
3189
|
+
hideTooltip();
|
|
3190
|
+
});
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
function renderHotspotSidebar() {
|
|
3194
|
+
const container = document.getElementById('hotspot-list');
|
|
3195
|
+
const currentData = recalculateHotspotData();
|
|
3196
|
+
const top = currentData.slice(0, 30);
|
|
3197
|
+
if (top.length === 0) {
|
|
3198
|
+
container.innerHTML = '<div style="color:#888">No active files in time window.</div>';
|
|
3199
|
+
return;
|
|
3200
|
+
}
|
|
3201
|
+
let html = '';
|
|
3202
|
+
for (let i = 0; i < top.length; i++) {
|
|
3203
|
+
const f = top[i];
|
|
3204
|
+
html += '<div class="hotspot-item"><div class="path">' + (i + 1) + '. ' + f.path +
|
|
3205
|
+
' <span class="risk-badge risk-' + f.riskLevel + '">' + f.riskLevel.toUpperCase() + '</span></div>' +
|
|
3206
|
+
'<div class="meta">Fam: ' + Math.round(f.familiarity * 100) + '% | Changes: ' + f.changeFrequency + ' | Risk: ' + f.risk.toFixed(2) + '</div></div>';
|
|
3207
|
+
}
|
|
3208
|
+
container.innerHTML = html;
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
// \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
|
|
3212
|
+
// \u2500\u2500 MULTI-USER TAB \u2500\u2500
|
|
3213
|
+
// \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
|
|
3214
|
+
|
|
3215
|
+
function initMultiUserSelect() {
|
|
3216
|
+
const select = document.getElementById('userSelect');
|
|
3217
|
+
select.innerHTML = '';
|
|
3218
|
+
multiUserNames.forEach((name, i) => {
|
|
3219
|
+
const opt = document.createElement('option');
|
|
3220
|
+
opt.value = i;
|
|
3221
|
+
const summary = multiUserSummaries[i];
|
|
3222
|
+
opt.textContent = name + ' (' + Math.round(summary.overallScore * 100) + '%)';
|
|
3223
|
+
select.appendChild(opt);
|
|
3224
|
+
});
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
function onUserChange() {
|
|
3228
|
+
currentUser = parseInt(document.getElementById('userSelect').value);
|
|
3229
|
+
renderMultiUser();
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
function getUserScore(node) {
|
|
3233
|
+
if (!node.userScores || node.userScores.length === 0) return node.score;
|
|
3234
|
+
const s = node.userScores[currentUser];
|
|
3235
|
+
return s ? s.score : 0;
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
function renderMultiUser() {
|
|
3239
|
+
const container = document.getElementById('tab-multiuser');
|
|
3240
|
+
container.innerHTML = '';
|
|
3241
|
+
const height = getContentHeight();
|
|
3242
|
+
const width = window.innerWidth;
|
|
3243
|
+
|
|
3244
|
+
const targetNode = multiuserPath ? findNode(multiUserData, multiuserPath) : multiUserData;
|
|
3245
|
+
if (!targetNode || !targetNode.children || targetNode.children.length === 0) return;
|
|
3246
|
+
|
|
3247
|
+
const hierarchyData = {
|
|
3248
|
+
name: targetNode.path || 'root',
|
|
3249
|
+
children: targetNode.children.map(c => buildHierarchy(c)),
|
|
3250
|
+
};
|
|
3251
|
+
|
|
3252
|
+
const root = d3.hierarchy(hierarchyData)
|
|
3253
|
+
.sum(d => d.value || 0)
|
|
3254
|
+
.sort((a, b) => (b.value || 0) - (a.value || 0));
|
|
3255
|
+
|
|
3256
|
+
d3.treemap().size([width, height]).padding(2).paddingTop(20).round(true)(root);
|
|
3257
|
+
|
|
3258
|
+
const svg = d3.select('#tab-multiuser').append('svg').attr('width', width).attr('height', height);
|
|
3259
|
+
const nodes = root.descendants().filter(d => d.depth > 0);
|
|
3260
|
+
|
|
3261
|
+
const groups = svg.selectAll('g').data(nodes).join('g')
|
|
3262
|
+
.attr('transform', d => 'translate(' + d.x0 + ',' + d.y0 + ')');
|
|
3263
|
+
|
|
3264
|
+
groups.append('rect')
|
|
3265
|
+
.attr('width', d => Math.max(0, d.x1 - d.x0))
|
|
3266
|
+
.attr('height', d => Math.max(0, d.y1 - d.y0))
|
|
3267
|
+
.attr('fill', d => d.data.data ? scoreColor(getUserScore(d.data.data)) : '#333')
|
|
3268
|
+
.attr('opacity', d => d.children ? 0.35 : 0.88)
|
|
3269
|
+
.attr('stroke', '#1a1a2e').attr('stroke-width', d => d.children ? 1 : 0.5).attr('rx', 2)
|
|
3270
|
+
.style('cursor', d => (d.data.data && d.data.data.type === 'folder') ? 'pointer' : 'default')
|
|
3271
|
+
.on('click', (event, d) => {
|
|
3272
|
+
if (d.data.data && d.data.data.type === 'folder') { event.stopPropagation(); zoomTo(d.data.data.path); }
|
|
3273
|
+
})
|
|
3274
|
+
.on('mouseover', function(event, d) {
|
|
3275
|
+
if (!d.data.data) return;
|
|
3276
|
+
d3.select(this).attr('opacity', d.children ? 0.5 : 1).attr('stroke', '#fff');
|
|
3277
|
+
let html = '<strong>' + (d.data.data.path || 'root') + '</strong>';
|
|
3278
|
+
if (d.data.data.userScores && d.data.data.userScores.length > 0) {
|
|
3279
|
+
html += '<table style="margin-top:6px;width:100%">';
|
|
3280
|
+
d.data.data.userScores.forEach((s, i) => {
|
|
3281
|
+
const isCurrent = (i === currentUser);
|
|
3282
|
+
const style = isCurrent ? 'font-weight:bold;color:#5eadf7' : '';
|
|
3283
|
+
html += '<tr style="' + style + '"><td>' + multiUserNames[i] + '</td><td style="text-align:right">' + Math.round(s.score * 100) + '%</td></tr>';
|
|
3284
|
+
});
|
|
3285
|
+
html += '</table>';
|
|
3286
|
+
}
|
|
3287
|
+
if (d.data.data.type === 'folder') {
|
|
3288
|
+
html += '<br>Files: ' + d.data.data.fileCount;
|
|
3289
|
+
html += '<br><em style="color:#5eadf7">Click to drill down \\u25B6</em>';
|
|
3290
|
+
} else {
|
|
3291
|
+
html += '<br>Lines: ' + d.data.data.lines.toLocaleString();
|
|
3292
|
+
}
|
|
3293
|
+
showTooltipAt(html, event);
|
|
3294
|
+
})
|
|
3295
|
+
.on('mousemove', moveTooltip)
|
|
3296
|
+
.on('mouseout', function(event, d) {
|
|
3297
|
+
d3.select(this).attr('opacity', d.children ? 0.35 : 0.88).attr('stroke', '#1a1a2e');
|
|
3298
|
+
hideTooltip();
|
|
3299
|
+
});
|
|
3300
|
+
|
|
3301
|
+
groups.append('text')
|
|
3302
|
+
.attr('x', 4).attr('y', 14).attr('fill', '#fff')
|
|
3303
|
+
.attr('font-size', d => d.children ? '11px' : '10px')
|
|
3304
|
+
.attr('font-weight', d => d.children ? 'bold' : 'normal')
|
|
3305
|
+
.style('pointer-events', 'none')
|
|
3306
|
+
.text(d => truncateLabel(d.data.name || '', d.x1 - d.x0, d.y1 - d.y0));
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
// \u2500\u2500 Init \u2500\u2500
|
|
3310
|
+
rendered.scoring = true;
|
|
3311
|
+
renderScoring();
|
|
3312
|
+
window.addEventListener('resize', () => {
|
|
3313
|
+
if (activeTab === 'scoring') renderScoring();
|
|
3314
|
+
else if (activeTab === 'coverage') renderCoverage();
|
|
3315
|
+
else if (activeTab === 'hotspots') renderHotspot();
|
|
3316
|
+
else if (activeTab === 'multiuser') renderMultiUser();
|
|
3317
|
+
});
|
|
3318
|
+
</script>
|
|
3319
|
+
</body>
|
|
3320
|
+
</html>`;
|
|
3321
|
+
}
|
|
3322
|
+
async function generateAndOpenUnifiedHTML(data, repoPath) {
|
|
3323
|
+
const html = generateUnifiedHTML(data);
|
|
3324
|
+
const outputPath = join5(repoPath, "gitfamiliar-dashboard.html");
|
|
3325
|
+
writeFileSync5(outputPath, html, "utf-8");
|
|
3326
|
+
console.log(`Dashboard generated: ${outputPath}`);
|
|
3327
|
+
await openBrowser(outputPath);
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3330
|
+
// src/cli/index.ts
|
|
3331
|
+
var require2 = createRequire(import.meta.url);
|
|
3332
|
+
var pkg = require2("../../package.json");
|
|
3333
|
+
function collect(value, previous) {
|
|
3334
|
+
return previous.concat([value]);
|
|
3335
|
+
}
|
|
3336
|
+
function createProgram() {
|
|
3337
|
+
const program2 = new Command();
|
|
3338
|
+
program2.name("gitfamiliar").description("Visualize your code familiarity from Git history").version(pkg.version).option(
|
|
3339
|
+
"-m, --mode <mode>",
|
|
3340
|
+
"Scoring mode: binary, authorship, weighted",
|
|
3341
|
+
"binary"
|
|
3342
|
+
).option(
|
|
3343
|
+
"-u, --user <user>",
|
|
3344
|
+
"Git user name or email (repeatable for comparison)",
|
|
3345
|
+
collect,
|
|
3346
|
+
[]
|
|
3347
|
+
).option(
|
|
3348
|
+
"-e, --expiration <policy>",
|
|
3349
|
+
"Expiration policy: never, time:180d, change:50%, combined:365d:50%",
|
|
3350
|
+
"never"
|
|
3351
|
+
).option("--html", "Generate HTML treemap report", false).option(
|
|
3352
|
+
"-w, --weights <weights>",
|
|
3353
|
+
'Weights for weighted mode: blame,commit (e.g., "0.5,0.5")'
|
|
3354
|
+
).option("--team", "Compare all contributors", false).option(
|
|
3355
|
+
"--team-coverage",
|
|
3356
|
+
"Show team coverage map (bus factor analysis)",
|
|
3357
|
+
false
|
|
3358
|
+
).option("--hotspot [mode]", "Hotspot analysis: personal (default) or team").option(
|
|
3359
|
+
"--window <days>",
|
|
3360
|
+
"Time window for hotspot analysis in days (default: 90)"
|
|
3361
|
+
).action(async (rawOptions) => {
|
|
3362
|
+
try {
|
|
3363
|
+
const repoPath = process.cwd();
|
|
3364
|
+
const options = parseOptions(rawOptions, repoPath);
|
|
3365
|
+
const isMultiUserCheck = options.team || Array.isArray(options.user) && options.user.length > 1;
|
|
3366
|
+
if (options.html && !options.hotspot && !options.teamCoverage && !isMultiUserCheck) {
|
|
3367
|
+
const data = await computeUnified(options);
|
|
3368
|
+
await generateAndOpenUnifiedHTML(data, repoPath);
|
|
3369
|
+
return;
|
|
3370
|
+
}
|
|
2111
3371
|
if (options.hotspot) {
|
|
2112
3372
|
const result2 = await computeHotspots(options);
|
|
2113
3373
|
if (options.html) {
|