// Git Graph visualization using D3.js with interactive base/head selection const GitGraph = { init(containerId, repoId) { this.container = document.getElementById(containerId); this.repoId = repoId; this.commits = []; this.refs = []; this.edges = []; // Selection state this.selectedBase = null; this.selectedHead = null; this.onSelectionChange = null; // callback(baseHash, headHash) 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}
`; } }, // Set selection programmatically (e.g. from dropdowns) setSelection(baseHash, headHash) { this.selectedBase = baseHash; this.selectedHead = headHash; this._updateSelectionVisuals(); }, 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 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 self = this; const nodes = g.selectAll('.node') .data(this.commits) .enter() .append('g') .attr('class', 'node') .attr('data-hash', d => d.hash) .attr('transform', (d, i) => `translate(${nodeMap[d.hash]?.x || 20},${nodeMap[d.hash]?.y || i * nodeHeight + 20})`) .style('cursor', 'pointer'); 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); }); // Hover effect nodes.on('mouseover', function(event, d) { d3.select(this).select('circle') .attr('r', 8) .attr('fill', '#2563eb'); }).on('mouseout', function(event, d) { const node = d3.select(this); const hash = d.hash; if (hash === self.selectedBase) { node.select('circle').attr('r', 9).attr('fill', '#10b981').attr('stroke', '#059669'); } else if (hash === self.selectedHead) { node.select('circle').attr('r', 9).attr('fill', '#f59e0b').attr('stroke', '#d97706'); } else { node.select('circle').attr('r', 6).attr('fill', '#3b82f6').attr('stroke', '#1e40af'); } }); // Click handler for base/head selection nodes.on('click', function(event, d) { const hash = d.hash; if (!self.selectedBase) { // First click: set base self.selectedBase = hash; } else if (!self.selectedHead) { // Second click: set head if (hash === self.selectedBase) { // Clicked same node, deselect base self.selectedBase = null; } else { self.selectedHead = hash; } } else { // Both selected: reset and start new selection self.selectedBase = hash; self.selectedHead = null; } self._updateSelectionVisuals(); // Notify callback if (self.onSelectionChange) { self.onSelectionChange(self.selectedBase, self.selectedHead); } }); // Store refs for later updates this._nodes = nodes; this._nodeMap = nodeMap; // Apply any pre-existing selection this._updateSelectionVisuals(); }, // Update visual state of selected nodes _updateSelectionVisuals() { if (!this._nodes) return; const self = this; this._nodes.each(function(d) { const node = d3.select(this); const circle = node.select('circle'); const hash = d.hash; if (hash === self.selectedBase) { circle.attr('r', 9).attr('fill', '#10b981').attr('stroke', '#059669'); } else if (hash === self.selectedHead) { circle.attr('r', 9).attr('fill', '#f59e0b').attr('stroke', '#d97706'); } else { circle.attr('r', 6).attr('fill', '#3b82f6').attr('stroke', '#1e40af'); } }); }, // Get the ref name (branch/tag) for a commit hash, or short hash if none getRefName(hash) { const ref = this.refs.find(r => r.hash === hash); if (ref) return ref.name; const commit = this.commits.find(c => c.hash === hash); return commit ? commit.short_hash : hash.substring(0, 7); }, 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;