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.
@@ -8,7 +8,7 @@ import {
8
8
  processBatch,
9
9
  resolveUser,
10
10
  walkFiles
11
- } from "../chunk-BQCHOSLA.js";
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.padEnd(16))} ${bar} ${pct.padStart(4)} (${readCount}/${folder.fileCount} files)`
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.padEnd(16))} ${bar} ${pct.padStart(4)}`
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/cli/index.ts
2079
- function collect(value, previous) {
2080
- return previous.concat([value]);
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
- function createProgram() {
2083
- const program2 = new Command();
2084
- program2.name("gitfamiliar").description("Visualize your code familiarity from Git history").version("0.1.1").option(
2085
- "-m, --mode <mode>",
2086
- "Scoring mode: binary, authorship, weighted",
2087
- "binary"
2088
- ).option(
2089
- "-u, --user <user>",
2090
- "Git user name or email (repeatable for comparison)",
2091
- collect,
2092
- []
2093
- ).option(
2094
- "-e, --expiration <policy>",
2095
- "Expiration policy: never, time:180d, change:50%, combined:365d:50%",
2096
- "never"
2097
- ).option("--html", "Generate HTML treemap report", false).option(
2098
- "-w, --weights <weights>",
2099
- 'Weights for weighted mode: blame,commit (e.g., "0.5,0.5")'
2100
- ).option("--team", "Compare all contributors", false).option(
2101
- "--team-coverage",
2102
- "Show team coverage map (bus factor analysis)",
2103
- false
2104
- ).option("--hotspot [mode]", "Hotspot analysis: personal (default) or team").option(
2105
- "--window <days>",
2106
- "Time window for hotspot analysis in days (default: 90)"
2107
- ).action(async (rawOptions) => {
2108
- try {
2109
- const repoPath = process.cwd();
2110
- const options = parseOptions(rawOptions, repoPath);
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) {