feat: 多分支图形可视化,彩色泳道和右对齐标签
This commit is contained in:
+341
-124
@@ -1,5 +1,20 @@
|
||||
// Git Graph visualization using D3.js with interactive base/head selection
|
||||
// Multi-branch layout with colored lanes and right-aligned branch labels
|
||||
const GitGraph = {
|
||||
// Branch color palette
|
||||
BRANCH_COLORS: [
|
||||
'#3b82f6', // blue
|
||||
'#f97316', // orange
|
||||
'#10b981', // emerald
|
||||
'#8b5cf6', // violet
|
||||
'#ef4444', // red
|
||||
'#06b6d4', // cyan
|
||||
'#f59e0b', // amber
|
||||
'#ec4899', // pink
|
||||
'#14b8a6', // teal
|
||||
'#6366f1', // indigo
|
||||
],
|
||||
|
||||
init(containerId, repoId) {
|
||||
this.container = document.getElementById(containerId);
|
||||
this.repoId = repoId;
|
||||
@@ -10,7 +25,7 @@ const GitGraph = {
|
||||
// Selection state
|
||||
this.selectedBase = null;
|
||||
this.selectedHead = null;
|
||||
this.onSelectionChange = null; // callback(baseHash, headHash)
|
||||
this.onSelectionChange = null;
|
||||
|
||||
if (!this.container) {
|
||||
console.error('Graph container not found:', containerId);
|
||||
@@ -36,7 +51,6 @@ const GitGraph = {
|
||||
}
|
||||
},
|
||||
|
||||
// Set selection programmatically (e.g. from dropdowns)
|
||||
setSelection(baseHash, headHash) {
|
||||
this.selectedBase = baseHash;
|
||||
this.selectedHead = headHash;
|
||||
@@ -49,17 +63,43 @@ const GitGraph = {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear container
|
||||
this.container.innerHTML = '';
|
||||
|
||||
const width = this.container.clientWidth || 800;
|
||||
const width = Math.max(this.container.clientWidth || 800, 600);
|
||||
const nodeHeight = 40;
|
||||
const laneWidth = 32;
|
||||
const height = Math.max(400, this.commits.length * nodeHeight + 60);
|
||||
const margin = { top: 20, right: 120, bottom: 20, left: 60 };
|
||||
const margin = { top: 20, right: 140, bottom: 20, left: 40 };
|
||||
|
||||
// Compute layout
|
||||
const lanes = this.assignLanes();
|
||||
this._lanes = lanes;
|
||||
this._laneBranchMap = {};
|
||||
this._mapBranchesToLanes(lanes, this._laneBranchMap);
|
||||
|
||||
// Debug: log lane assignments
|
||||
const laneCounts = {};
|
||||
Object.values(lanes).forEach(l => { laneCounts[l] = (laneCounts[l] || 0) + 1; });
|
||||
console.log('=== Git Graph Debug ===');
|
||||
console.log('Lane distribution:', laneCounts);
|
||||
console.log('Total unique lanes:', Object.keys(laneCounts).length);
|
||||
console.log('Branches:', this.refs.filter(r => !r.is_tag).map(r => r.name));
|
||||
const merges = this.commits.filter(c => c.parent_ids && c.parent_ids.length > 1);
|
||||
console.log('Merge commits:', merges.length);
|
||||
merges.slice(0, 5).forEach(c => {
|
||||
console.log(` ${c.short_hash} lane=${lanes[c.hash]} parents=${c.parent_ids.length} msg="${c.message.substring(0,40)}"`);
|
||||
c.parent_ids.forEach((p, i) => {
|
||||
const inList = this.commits.some(cc => cc.hash === p);
|
||||
const pLane = lanes[p];
|
||||
console.log(` parent[${i}]: ${p.substring(0,7)} inList=${inList} lane=${pLane}`);
|
||||
});
|
||||
});
|
||||
const maxLane = Math.max(0, ...Object.values(lanes));
|
||||
const graphWidth = (maxLane + 1) * laneWidth + margin.left + margin.right;
|
||||
|
||||
const svg = d3.select(this.container)
|
||||
.append('svg')
|
||||
.attr('width', width)
|
||||
.attr('width', Math.max(width, graphWidth))
|
||||
.attr('height', height);
|
||||
|
||||
const g = svg.append('g')
|
||||
@@ -67,20 +107,112 @@ const GitGraph = {
|
||||
|
||||
// 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,
|
||||
x: (lanes[commit.hash] || 0) * laneWidth,
|
||||
y: i * nodeHeight + 20,
|
||||
lane: lanes[commit.hash] || 0,
|
||||
commit
|
||||
};
|
||||
});
|
||||
|
||||
// Draw edges
|
||||
// Draw colored lane lines (vertical lines through each branch's commits)
|
||||
this._drawLaneLines(g, nodeMap, this._laneBranchMap, laneWidth, height);
|
||||
|
||||
// Draw edges (parent-child connections)
|
||||
this._drawEdges(g, nodeMap, lanes);
|
||||
|
||||
// Draw commit nodes
|
||||
this._drawNodes(g, nodeMap, lanes);
|
||||
|
||||
// Draw branch labels on the right side
|
||||
this._drawBranchLabels(g, nodeMap, this._laneBranchMap, laneWidth, maxLane);
|
||||
|
||||
// Store for updates
|
||||
this._nodeMap = nodeMap;
|
||||
|
||||
// Apply any pre-existing selection
|
||||
this._updateSelectionVisuals();
|
||||
},
|
||||
|
||||
// Map branch refs to their lanes
|
||||
_mapBranchesToLanes(lanes, laneBranchMap) {
|
||||
// First, map refs to lanes
|
||||
this.refs.forEach(ref => {
|
||||
if (ref.is_tag) return;
|
||||
const lane = lanes[ref.hash];
|
||||
if (lane !== undefined && !laneBranchMap[lane]) {
|
||||
laneBranchMap[lane] = {
|
||||
name: ref.name,
|
||||
color: ref.is_head ? '#8b5cf6' : this._getBranchColor(ref.name),
|
||||
isHead: ref.is_head
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure lane 0 always has a color
|
||||
if (!laneBranchMap[0]) {
|
||||
laneBranchMap[0] = {
|
||||
name: 'main',
|
||||
color: this.BRANCH_COLORS[0],
|
||||
isHead: false
|
||||
};
|
||||
}
|
||||
|
||||
// For any lane that has commits but no branch ref, create an anonymous label
|
||||
const maxLane = Math.max(0, ...Object.values(lanes));
|
||||
for (let i = 0; i <= maxLane; i++) {
|
||||
if (!laneBranchMap[i]) {
|
||||
laneBranchMap[i] = {
|
||||
name: `branch-${i}`,
|
||||
color: this.BRANCH_COLORS[i % this.BRANCH_COLORS.length],
|
||||
isHead: false
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_getBranchColor(branchName) {
|
||||
// Deterministic color based on branch name
|
||||
let hash = 0;
|
||||
for (let i = 0; i < branchName.length; i++) {
|
||||
hash = ((hash << 5) - hash) + branchName.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return this.BRANCH_COLORS[Math.abs(hash) % this.BRANCH_COLORS.length];
|
||||
},
|
||||
|
||||
// Draw vertical colored lines connecting consecutive commits in each lane
|
||||
_drawLaneLines(g, nodeMap, laneBranchMap, laneWidth, height) {
|
||||
Object.entries(laneBranchMap).forEach(([lane, info]) => {
|
||||
const laneNum = parseInt(lane);
|
||||
|
||||
// Find all nodes in this lane, sorted by Y
|
||||
const nodesInLane = Object.values(nodeMap)
|
||||
.filter(n => n.lane === laneNum)
|
||||
.sort((a, b) => a.y - b.y);
|
||||
if (nodesInLane.length < 2) return;
|
||||
|
||||
// Draw line segments between consecutive commits
|
||||
for (let i = 0; i < nodesInLane.length - 1; i++) {
|
||||
const a = nodesInLane[i];
|
||||
const b = nodesInLane[i + 1];
|
||||
g.append('line')
|
||||
.attr('x1', a.x)
|
||||
.attr('y1', a.y)
|
||||
.attr('x2', b.x)
|
||||
.attr('y2', b.y)
|
||||
.attr('stroke', info.color)
|
||||
.attr('stroke-width', 2)
|
||||
.attr('stroke-opacity', 0.4);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Draw edges between commits
|
||||
_drawEdges(g, nodeMap, lanes) {
|
||||
const self = this;
|
||||
g.selectAll('.edge')
|
||||
.data(this.edges.filter(e => nodeMap[e.source] && nodeMap[e.target]))
|
||||
.enter()
|
||||
@@ -90,31 +222,63 @@ const GitGraph = {
|
||||
const s = nodeMap[d.source];
|
||||
const t = nodeMap[d.target];
|
||||
if (!s || !t) return '';
|
||||
|
||||
// If same lane, draw straight vertical line
|
||||
if (s.lane === t.lane) {
|
||||
return `M ${s.x} ${s.y} L ${t.x} ${t.y}`;
|
||||
}
|
||||
|
||||
// Different lanes: draw curved connection (merge/diverge)
|
||||
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);
|
||||
.attr('stroke', d => {
|
||||
// Color edge by source commit's lane
|
||||
const sourceLane = lanes[d.source];
|
||||
const info = this._findLaneInfo(sourceLane);
|
||||
return info ? info.color : '#94a3b8';
|
||||
})
|
||||
.attr('stroke-width', 2)
|
||||
.attr('stroke-opacity', 0.6);
|
||||
},
|
||||
|
||||
_findLaneInfo(lane) {
|
||||
return this._laneBranchMap ? this._laneBranchMap[lane] : null;
|
||||
},
|
||||
|
||||
// Draw commit nodes
|
||||
_drawNodes(g, nodeMap, lanes) {
|
||||
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})`)
|
||||
.attr('transform', d => {
|
||||
const pos = nodeMap[d.hash];
|
||||
return `translate(${pos ? pos.x : 0},${pos ? pos.y : 0})`;
|
||||
})
|
||||
.style('cursor', 'pointer');
|
||||
|
||||
// Node circles - colored by lane
|
||||
nodes.append('circle')
|
||||
.attr('r', 6)
|
||||
.attr('fill', '#3b82f6')
|
||||
.attr('stroke', '#1e40af')
|
||||
.attr('fill', d => {
|
||||
const lane = lanes[d.hash];
|
||||
const info = this._findLaneInfo(lane);
|
||||
return info ? info.color : '#3b82f6';
|
||||
})
|
||||
.attr('stroke', d => {
|
||||
const lane = lanes[d.hash];
|
||||
const info = this._findLaneInfo(lane);
|
||||
return info ? d3.color(info.color).darker(0.5) : '#1e40af';
|
||||
})
|
||||
.attr('stroke-width', 2);
|
||||
|
||||
// Add commit message
|
||||
// Commit message
|
||||
nodes.append('text')
|
||||
.attr('x', 16)
|
||||
.attr('y', 4)
|
||||
@@ -122,7 +286,7 @@ const GitGraph = {
|
||||
.attr('font-size', '12px')
|
||||
.attr('fill', '#374151');
|
||||
|
||||
// Add short hash
|
||||
// Short hash
|
||||
nodes.append('text')
|
||||
.attr('x', 16)
|
||||
.attr('y', -8)
|
||||
@@ -131,53 +295,10 @@ const GitGraph = {
|
||||
.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');
|
||||
.attr('r', 8);
|
||||
}).on('mouseout', function(event, d) {
|
||||
const node = d3.select(this);
|
||||
const hash = d.hash;
|
||||
@@ -186,50 +307,130 @@ const GitGraph = {
|
||||
} 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');
|
||||
const lane = lanes[hash];
|
||||
const info = self._findLaneInfo(lane);
|
||||
node.select('circle')
|
||||
.attr('r', 6)
|
||||
.attr('fill', info ? info.color : '#3b82f6')
|
||||
.attr('stroke', info ? d3.color(info.color).darker(0.5) : '#1e40af');
|
||||
}
|
||||
});
|
||||
|
||||
// Click handler for base/head selection
|
||||
// Click handler
|
||||
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
|
||||
// Draw branch labels on the right side, aligned with lanes
|
||||
_drawBranchLabels(g, nodeMap, laneBranchMap, laneWidth, maxLane) {
|
||||
const self = this;
|
||||
|
||||
// For each branch, find the first commit (topmost) in its lane
|
||||
// and place the label at that Y position on the right side
|
||||
Object.entries(laneBranchMap).forEach(([lane, info]) => {
|
||||
const laneNum = parseInt(lane);
|
||||
const nodesInLane = Object.values(nodeMap).filter(n => n.lane === laneNum);
|
||||
if (nodesInLane.length === 0) return;
|
||||
|
||||
// Use the first commit's Y position for the label
|
||||
const firstNode = nodesInLane[0];
|
||||
const labelX = (maxLane + 1) * laneWidth + 16;
|
||||
const labelY = firstNode.y;
|
||||
|
||||
const group = g.append('g')
|
||||
.attr('class', 'branch-label')
|
||||
.attr('transform', `translate(${labelX},${labelY - 8})`);
|
||||
|
||||
// Background pill
|
||||
const text = group.append('text')
|
||||
.attr('x', 8)
|
||||
.attr('y', 12)
|
||||
.text(info.name)
|
||||
.attr('font-size', '11px')
|
||||
.attr('fill', info.color)
|
||||
.attr('font-weight', '600')
|
||||
.attr('font-family', 'system-ui, -apple-system, sans-serif');
|
||||
|
||||
const textBBox = text.node().getBBox();
|
||||
|
||||
group.insert('rect', 'text')
|
||||
.attr('x', 0)
|
||||
.attr('y', 0)
|
||||
.attr('width', textBBox.width + 16)
|
||||
.attr('height', textBBox.height + 8)
|
||||
.attr('rx', 10)
|
||||
.attr('ry', 10)
|
||||
.attr('fill', info.color)
|
||||
.attr('opacity', 0.12);
|
||||
|
||||
// If HEAD, add a dot indicator
|
||||
if (info.isHead) {
|
||||
group.append('circle')
|
||||
.attr('cx', -6)
|
||||
.attr('cy', 8)
|
||||
.attr('r', 3)
|
||||
.attr('fill', info.color);
|
||||
}
|
||||
});
|
||||
|
||||
// Also draw tags on the right side
|
||||
this.refs.filter(r => r.is_tag).forEach(ref => {
|
||||
const pos = nodeMap[ref.hash];
|
||||
if (!pos) return;
|
||||
|
||||
const labelX = (maxLane + 1) * laneWidth + 16;
|
||||
const labelY = pos.y;
|
||||
|
||||
const group = g.append('g')
|
||||
.attr('class', 'tag-label')
|
||||
.attr('transform', `translate(${labelX},${labelY - 8})`);
|
||||
|
||||
const text = group.append('text')
|
||||
.attr('x', 8)
|
||||
.attr('y', 12)
|
||||
.text(ref.name)
|
||||
.attr('font-size', '11px')
|
||||
.attr('fill', '#10b981')
|
||||
.attr('font-weight', '500');
|
||||
|
||||
const textBBox = text.node().getBBox();
|
||||
|
||||
group.insert('rect', 'text')
|
||||
.attr('x', 0)
|
||||
.attr('y', 0)
|
||||
.attr('width', textBBox.width + 16)
|
||||
.attr('height', textBBox.height + 8)
|
||||
.attr('rx', 10)
|
||||
.attr('ry', 10)
|
||||
.attr('fill', '#10b981')
|
||||
.attr('opacity', 0.12);
|
||||
});
|
||||
},
|
||||
|
||||
_updateSelectionVisuals() {
|
||||
if (!this._nodes) return;
|
||||
if (!this._nodes || !this._lanes) return;
|
||||
|
||||
const self = this;
|
||||
this._nodes.each(function(d) {
|
||||
@@ -242,12 +443,16 @@ const GitGraph = {
|
||||
} 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');
|
||||
const lane = self._lanes[hash];
|
||||
const info = self._findLaneInfo(lane);
|
||||
circle
|
||||
.attr('r', 6)
|
||||
.attr('fill', info ? info.color : '#3b82f6')
|
||||
.attr('stroke', info ? d3.color(info.color).darker(0.5) : '#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;
|
||||
@@ -255,63 +460,76 @@ const GitGraph = {
|
||||
return commit ? commit.short_hash : hash.substring(0, 7);
|
||||
},
|
||||
|
||||
// Lane assignment: HEAD branch gets lane 0, other branches get sequential lanes
|
||||
// Merge commits stay on first-parent lane, second parent starts new lane
|
||||
assignLanes() {
|
||||
// Simple lane assignment: find branches and assign lanes
|
||||
const lanes = {};
|
||||
const branchHeads = {};
|
||||
const commitMap = {};
|
||||
this.commits.forEach(c => { commitMap[c.hash] = c; });
|
||||
|
||||
// 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();
|
||||
let nextLane = 0;
|
||||
|
||||
// First assign HEAD branch
|
||||
// Helper: BFS from a commit backwards through parents, assigning a lane
|
||||
const bfsFrom = (startHash, lane) => {
|
||||
const q = [startHash];
|
||||
while (q.length > 0) {
|
||||
const hash = q.shift();
|
||||
if (visited.has(hash)) continue;
|
||||
if (!commitMap[hash]) continue;
|
||||
visited.add(hash);
|
||||
lanes[hash] = lane;
|
||||
|
||||
const commit = commitMap[hash];
|
||||
const parents = commit.parent_ids || [];
|
||||
if (parents.length === 0) continue;
|
||||
|
||||
if (parents.length === 1) {
|
||||
// Linear: parent continues in same lane
|
||||
if (!visited.has(parents[0])) q.push(parents[0]);
|
||||
} else {
|
||||
// Merge: first parent stays, others get new lanes later
|
||||
if (!visited.has(parents[0])) q.push(parents[0]);
|
||||
// Don't traverse other parents here — they'll be handled
|
||||
// when we process branch refs below
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Step 1: HEAD branch gets lane 0
|
||||
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;
|
||||
bfsFrom(headRef.hash, nextLane);
|
||||
nextLane++;
|
||||
} else if (this.commits.length > 0) {
|
||||
bfsFrom(this.commits[0].hash, nextLane);
|
||||
nextLane++;
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
// Step 2: Each non-tag, non-HEAD ref gets its own lane
|
||||
this.refs.filter(r => !r.is_tag && !r.is_head).forEach(ref => {
|
||||
if (visited.has(ref.hash)) return; // already on some lane
|
||||
bfsFrom(ref.hash, nextLane);
|
||||
nextLane++;
|
||||
});
|
||||
|
||||
// Assign any remaining commits
|
||||
// Step 3: Handle merge second-parents that weren't reached by refs
|
||||
// Walk all commits; if a merge commit has a second parent not yet visited,
|
||||
// give it a new lane
|
||||
this.commits.forEach(commit => {
|
||||
if (!lanes[commit.hash]) {
|
||||
const parents = commit.parent_ids || [];
|
||||
if (parents.length <= 1) return;
|
||||
for (let i = 1; i < parents.length; i++) {
|
||||
if (!visited.has(parents[i]) && commitMap[parents[i]]) {
|
||||
bfsFrom(parents[i], nextLane);
|
||||
nextLane++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Assign any remaining unvisited commits to lane 0
|
||||
this.commits.forEach(commit => {
|
||||
if (lanes[commit.hash] === undefined) {
|
||||
lanes[commit.hash] = 0;
|
||||
}
|
||||
});
|
||||
@@ -320,5 +538,4 @@ const GitGraph = {
|
||||
}
|
||||
};
|
||||
|
||||
// Export for use
|
||||
window.GitGraph = GitGraph;
|
||||
|
||||
Reference in New Issue
Block a user