// Git Graph visualization using D3.js const GitGraph = { init(containerId, repoId) { this.container = document.getElementById(containerId); this.repoId = repoId; this.commits = []; this.refs = []; this.edges = []; if (!this.container) { console.error('Graph container not found:', containerId); return; } this.load(); }, async load() { try { const resp = await fetch(`/api/repos/${this.repoId}/graph`); if (!resp.ok) throw new Error('Failed to load graph'); const data = await resp.json(); this.commits = data.commits || []; this.refs = data.refs || []; this.edges = data.edges || []; this.render(); } catch (err) { this.container.innerHTML = `
Error loading graph: ${err.message}
`; } }, render() { if (this.commits.length === 0) { this.container.innerHTML = '
No commits found
'; return; } // Clear container this.container.innerHTML = ''; const width = this.container.clientWidth || 800; const nodeHeight = 40; const height = Math.max(400, this.commits.length * nodeHeight + 60); const margin = { top: 20, right: 120, bottom: 20, left: 60 }; const svg = d3.select(this.container) .append('svg') .attr('width', width) .attr('height', height); const g = svg.append('g') .attr('transform', `translate(${margin.left},${margin.top})`); // Build node positions const nodeMap = {}; const laneWidth = 24; // Assign lanes to commits (simple algorithm: each branch gets its own lane) const lanes = this.assignLanes(); this.commits.forEach((commit, i) => { nodeMap[commit.hash] = { x: (lanes[commit.hash] || 0) * laneWidth + 20, y: i * nodeHeight + 20, commit }; }); // Draw edges g.selectAll('.edge') .data(this.edges.filter(e => nodeMap[e.source] && nodeMap[e.target])) .enter() .append('path') .attr('class', 'edge') .attr('d', d => { const s = nodeMap[d.source]; const t = nodeMap[d.target]; if (!s || !t) return ''; const midY = (s.y + t.y) / 2; return `M ${s.x} ${s.y} C ${s.x} ${midY}, ${t.x} ${midY}, ${t.x} ${t.y}`; }) .attr('fill', 'none') .attr('stroke', '#94a3b8') .attr('stroke-width', 2); // Draw commit nodes const nodes = g.selectAll('.node') .data(this.commits) .enter() .append('g') .attr('class', 'node') .attr('transform', (d, i) => `translate(${nodeMap[d.hash]?.x || 20},${nodeMap[d.hash]?.y || i * nodeHeight + 20})`); nodes.append('circle') .attr('r', 6) .attr('fill', '#3b82f6') .attr('stroke', '#1e40af') .attr('stroke-width', 2); // Add commit message nodes.append('text') .attr('x', 16) .attr('y', 4) .text(d => d.message.substring(0, 60) + (d.message.length > 60 ? '...' : '')) .attr('font-size', '12px') .attr('fill', '#374151'); // Add short hash nodes.append('text') .attr('x', 16) .attr('y', -8) .text(d => d.short_hash) .attr('font-size', '10px') .attr('fill', '#6b7280') .attr('font-family', 'monospace'); // Draw refs const refGroups = g.selectAll('.ref') .data(this.refs) .enter() .append('g') .attr('class', 'ref'); refGroups.each((ref, i, elements) => { const commitNode = nodeMap[ref.hash]; if (!commitNode) return; const group = d3.select(elements[i]); const isTag = ref.is_tag; const color = isTag ? '#10b981' : (ref.is_head ? '#8b5cf6' : '#3b82f6'); const bbox = { x: commitNode.x + 30, y: commitNode.y - 10 }; group.append('rect') .attr('x', bbox.x) .attr('y', bbox.y) .attr('rx', 4) .attr('ry', 4) .attr('fill', color) .attr('opacity', 0.1) .attr('stroke', color) .attr('stroke-width', 1); const text = group.append('text') .attr('x', bbox.x + 6) .attr('y', bbox.y + 14) .text(ref.name) .attr('font-size', '11px') .attr('fill', color) .attr('font-weight', '500'); // Size rect to text const textBBox = text.node().getBBox(); group.select('rect') .attr('width', textBBox.width + 12) .attr('height', textBBox.height + 6); }); // Add hover effect nodes.on('mouseover', function(event, d) { d3.select(this).select('circle') .attr('r', 8) .attr('fill', '#2563eb'); }).on('mouseout', function() { d3.select(this).select('circle') .attr('r', 6) .attr('fill', '#3b82f6'); }); }, assignLanes() { // Simple lane assignment: find branches and assign lanes const lanes = {}; const branchHeads = {}; // Find branch head commits this.refs.forEach(ref => { if (!ref.is_tag) { branchHeads[ref.hash] = ref.name; } }); // BFS from branch heads to assign lanes let nextLane = 0; const visited = new Set(); // First assign HEAD branch const headRef = this.refs.find(r => r.is_head); if (headRef) { const queue = [headRef.hash]; while (queue.length > 0) { const hash = queue.shift(); if (visited.has(hash)) continue; visited.add(hash); lanes[hash] = 0; // Find children (commits that have this as parent) const children = this.edges.filter(e => e.target === hash).map(e => e.source); children.forEach(child => { if (!visited.has(child)) queue.push(child); }); } nextLane = 1; } // Assign lanes to other branches this.refs.filter(r => !r.is_head && !r.is_tag).forEach(ref => { if (visited.has(ref.hash)) return; const queue = [ref.hash]; while (queue.length > 0) { const hash = queue.shift(); if (visited.has(hash)) continue; visited.add(hash); lanes[hash] = nextLane; const children = this.edges.filter(e => e.target === hash).map(e => e.source); children.forEach(child => { if (!visited.has(child)) queue.push(child); }); } nextLane++; }); // Assign any remaining commits this.commits.forEach(commit => { if (!lanes[commit.hash]) { lanes[commit.hash] = 0; } }); return lanes; } }; // Export for use window.GitGraph = GitGraph;