diff --git a/packages/mermaid/src/diagrams/git/gitGraph.parser.spec.ts b/packages/mermaid/src/diagrams/git/gitGraph.parser.spec.ts index 2c86d7c7b..1a38a8217 100644 --- a/packages/mermaid/src/diagrams/git/gitGraph.parser.spec.ts +++ b/packages/mermaid/src/diagrams/git/gitGraph.parser.spec.ts @@ -1,7 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { parser } from './gitGraphParser.js'; -import db from './gitGraphAst.js'; -import { parse } from 'path'; +import { db } from './gitGraphAst.js'; const parseInput = async (input: string) => { await parser.parse(input); diff --git a/packages/mermaid/src/diagrams/git/gitGraph.spec.ts b/packages/mermaid/src/diagrams/git/gitGraph.spec.ts index ab315a82a..9b3236f90 100644 --- a/packages/mermaid/src/diagrams/git/gitGraph.spec.ts +++ b/packages/mermaid/src/diagrams/git/gitGraph.spec.ts @@ -1,5 +1,5 @@ import { rejects } from 'assert'; -import db from './gitGraphAst.js'; +import { db } from './gitGraphAst.js'; import { parser } from './gitGraphParser.js'; describe('when parsing a gitGraph', function () { diff --git a/packages/mermaid/src/diagrams/git/gitGraphAst.ts b/packages/mermaid/src/diagrams/git/gitGraphAst.ts index 31302133a..53c361e3e 100644 --- a/packages/mermaid/src/diagrams/git/gitGraphAst.ts +++ b/packages/mermaid/src/diagrams/git/gitGraphAst.ts @@ -12,9 +12,8 @@ import { getDiagramTitle, } from '../common/commonDb.js'; import defaultConfig from '../../defaultConfig.js'; -import type { DiagramOrientation, Commit } from './gitGraphTypes.js'; +import type { DiagramOrientation, Commit, GitGraphDB, CommitType } from './gitGraphTypes.js'; import { ImperativeState } from '../../utils/imperativeState.js'; - interface GitGraphState { commits: Map; head: Commit | null; @@ -467,7 +466,7 @@ export const getHead = function () { return state.records.head; }; -export const commitType = { +export const commitType: CommitType = { NORMAL: 0, REVERSE: 1, HIGHLIGHT: 2, @@ -475,7 +474,7 @@ export const commitType = { CHERRY_PICK: 4, }; -export default { +export const db: GitGraphDB = { commitType, getConfig: () => getConfig().gitGraph, setDirection, diff --git a/packages/mermaid/src/diagrams/git/gitGraphDiagram.ts b/packages/mermaid/src/diagrams/git/gitGraphDiagram.ts index e5534140b..01537e551 100644 --- a/packages/mermaid/src/diagrams/git/gitGraphDiagram.ts +++ b/packages/mermaid/src/diagrams/git/gitGraphDiagram.ts @@ -1,13 +1,13 @@ // @ts-ignore: JISON doesn't support types import { parser } from './gitGraphParser.js'; -import gitGraphDb from './gitGraphAst.js'; +import { db } from './gitGraphAst.js'; import gitGraphRenderer from './gitGraphRenderer.js'; import gitGraphStyles from './styles.js'; import type { DiagramDefinition } from '../../diagram-api/types.js'; export const diagram: DiagramDefinition = { parser: parser, - db: gitGraphDb, + db: db, renderer: gitGraphRenderer, styles: gitGraphStyles, }; diff --git a/packages/mermaid/src/diagrams/git/gitGraphParser.ts b/packages/mermaid/src/diagrams/git/gitGraphParser.ts index 93a671993..b014d7e57 100644 --- a/packages/mermaid/src/diagrams/git/gitGraphParser.ts +++ b/packages/mermaid/src/diagrams/git/gitGraphParser.ts @@ -3,7 +3,7 @@ import { parse } from '@mermaid-js/parser'; import type { ParserDefinition } from '../../diagram-api/types.js'; import { log } from '../../logger.js'; import { populateCommonDb } from '../common/populateCommonDb.js'; -import db from './gitGraphAst.js'; +import { db } from './gitGraphAst.js'; import { commitType } from './gitGraphAst.js'; import type { CheckoutAst, diff --git a/packages/mermaid/src/diagrams/git/gitGraphRenderer.js b/packages/mermaid/src/diagrams/git/gitGraphRenderer.js deleted file mode 100644 index b8b13e089..000000000 --- a/packages/mermaid/src/diagrams/git/gitGraphRenderer.js +++ /dev/null @@ -1,893 +0,0 @@ -import { select } from 'd3'; -import { getConfig, setupGraphViewbox } from '../../diagram-api/diagramAPI.js'; -import { log } from '../../logger.js'; -import utils from '../../utils.js'; - -/** - * @typedef {Map} CommitMap - */ - -/** @type {CommitMap} */ -let allCommitsDict = new Map(); - -const commitType = { - NORMAL: 0, - REVERSE: 1, - HIGHLIGHT: 2, - MERGE: 3, - CHERRY_PICK: 4, -}; - -const THEME_COLOR_LIMIT = 8; - -let branchPos = {}; -let commitPos = {}; -let lanes = []; -let maxPos = 0; -let dir = 'LR'; -let defaultPos = 30; -const clear = () => { - branchPos = new Map(); - commitPos = new Map(); - allCommitsDict = new Map(); - maxPos = 0; - lanes = []; - dir = 'LR'; -}; - -/** - * Draws a text, used for labels of the branches - * - * @param {string} txt The text - * @returns {SVGElement} - */ -const drawText = (txt) => { - const svgLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - let rows = []; - - // Handling of new lines in the label - if (typeof txt === 'string') { - rows = txt.split(/\\n|\n|/gi); - } else if (Array.isArray(txt)) { - rows = txt; - } else { - rows = []; - } - - for (const row of rows) { - const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan'); - tspan.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve'); - tspan.setAttribute('dy', '1em'); - tspan.setAttribute('x', '0'); - tspan.setAttribute('class', 'row'); - tspan.textContent = row.trim(); - svgLabel.appendChild(tspan); - } - /** - * @param svg - * @param selector - */ - return svgLabel; -}; - -/** - * Searches for the closest parent from the parents list passed as argument. - * The parents list comes from an individual commit. The closest parent is actually - * the one farther down the graph, since that means it is closer to its child. - * - * @param {string[]} parents - * @returns {string | undefined} - */ -const findClosestParent = (parents) => { - let closestParent = ''; - let maxPosition = 0; - - parents.forEach((parent) => { - const parentPosition = - dir === 'TB' || dir === 'BT' ? commitPos.get(parent).y : commitPos.get(parent).x; - if (parentPosition >= maxPosition) { - closestParent = parent; - maxPosition = parentPosition; - } - }); - - return closestParent || undefined; -}; - -/** - * Searches for the closest parent from the parents list passed as argument for Bottom-to-Top orientation. - * The parents list comes from an individual commit. The closest parent is actually - * the one farther down the graph, since that means it is closer to its child. - * - * @param {string[]} parents - * @returns {string | undefined} - */ -const findClosestParentBT = (parents) => { - let closestParent = ''; - let maxPosition = Infinity; - - parents.forEach((parent) => { - const parentPosition = commitPos.get(parent).y; - if (parentPosition <= maxPosition) { - closestParent = parent; - maxPosition = parentPosition; - } - }); - - return closestParent || undefined; -}; - -/** - * Sets the position of the commit elements when the orientation is set to BT-Parallel. - * This is needed to render the chart in Bottom-to-Top mode while keeping the parallel - * commits in the correct position. First, it finds the correct position of the root commit - * using the findClosestParent method. Then, it uses the findClosestParentBT to set the position - * of the remaining commits. - * - * @param {any} sortedKeys - * @param {CommitMap} commits - * @param {any} defaultPos - * @param {any} commitStep - * @param {any} layoutOffset - */ -const setParallelBTPos = (sortedKeys, commits, defaultPos, commitStep, layoutOffset) => { - let curPos = defaultPos; - let maxPosition = defaultPos; - let roots = []; - sortedKeys.forEach((key) => { - const commit = commits.get(key); - if (commit.parents.length) { - const closestParent = findClosestParent(commit.parents); - curPos = commitPos.get(closestParent).y + commitStep; - if (curPos >= maxPosition) { - maxPosition = curPos; - } - } else { - roots.push(commit); - } - const x = branchPos.get(commit.branch).pos; - const y = curPos + layoutOffset; - commitPos.set(commit.id, { x: x, y: y }); - }); - curPos = maxPosition; - roots.forEach((commit) => { - const posWithOffset = curPos + defaultPos; - const y = posWithOffset; - const x = branchPos.get(commit.branch).pos; - commitPos.set(commit.id, { x: x, y: y }); - }); - sortedKeys.forEach((key) => { - const commit = commits.get(key); - if (commit.parents.length) { - const closestParent = findClosestParentBT(commit.parents); - curPos = commitPos.get(closestParent).y - commitStep; - if (curPos <= maxPosition) { - maxPosition = curPos; - } - const x = branchPos.get(commit.branch).pos; - const y = curPos - layoutOffset; - commitPos.set(commit.id, { x: x, y: y }); - } - }); -}; - -/** - * Draws the commits with its symbol and labels. The function has two modes, one which only - * calculates the positions and one that does the actual drawing. This for a simple way getting the - * vertical layering correct in the graph. - * - * @param {any} svg - * @param {CommitMap} commits - * @param {any} modifyGraph - */ -const drawCommits = (svg, commits, modifyGraph) => { - const gitGraphConfig = getConfig().gitGraph; - const gBullets = svg.append('g').attr('class', 'commit-bullets'); - const gLabels = svg.append('g').attr('class', 'commit-labels'); - let pos = 0; - - if (dir === 'TB' || dir === 'BT') { - pos = defaultPos; - } - const keys = [...commits.keys()]; - const isParallelCommits = gitGraphConfig.parallelCommits; - const layoutOffset = 10; - const commitStep = 40; - let sortedKeys = - dir !== 'BT' || (dir === 'BT' && isParallelCommits) - ? keys.sort((a, b) => { - return commits.get(a).seq - commits.get(b).seq; - }) - : keys - .sort((a, b) => { - return commits.get(a).seq - commits.get(b).seq; - }) - .reverse(); - - if (dir === 'BT' && isParallelCommits) { - setParallelBTPos(sortedKeys, commits, pos, commitStep, layoutOffset); - sortedKeys = sortedKeys.reverse(); - } - sortedKeys.forEach((key) => { - const commit = commits.get(key); - if (isParallelCommits) { - if (commit.parents.length) { - const closestParent = - dir === 'BT' ? findClosestParentBT(commit.parents) : findClosestParent(commit.parents); - if (dir === 'TB') { - pos = commitPos.get(closestParent).y + commitStep; - } else if (dir === 'BT') { - pos = commitPos.get(key).y - commitStep; - } else { - pos = commitPos.get(closestParent).x + commitStep; - } - } else { - if (dir === 'TB') { - pos = defaultPos; - } else if (dir === 'BT') { - pos = commitPos.get(key).y - commitStep; - } else { - pos = 0; - } - } - } - const posWithOffset = dir === 'BT' && isParallelCommits ? pos : pos + layoutOffset; - const y = dir === 'TB' || dir === 'BT' ? posWithOffset : branchPos.get(commit.branch).pos; - const x = dir === 'TB' || dir === 'BT' ? branchPos.get(commit.branch).pos : posWithOffset; - - // Don't draw the commits now but calculate the positioning which is used by the branch lines etc. - if (modifyGraph) { - let typeClass; - let commitSymbolType = - commit.customType !== undefined && commit.customType !== '' - ? commit.customType - : commit.type; - switch (commitSymbolType) { - case commitType.NORMAL: - typeClass = 'commit-normal'; - break; - case commitType.REVERSE: - typeClass = 'commit-reverse'; - break; - case commitType.HIGHLIGHT: - typeClass = 'commit-highlight'; - break; - case commitType.MERGE: - typeClass = 'commit-merge'; - break; - case commitType.CHERRY_PICK: - typeClass = 'commit-cherry-pick'; - break; - default: - typeClass = 'commit-normal'; - } - - if (commitSymbolType === commitType.HIGHLIGHT) { - const circle = gBullets.append('rect'); - circle.attr('x', x - 10); - circle.attr('y', y - 10); - circle.attr('height', 20); - circle.attr('width', 20); - circle.attr( - 'class', - `commit ${commit.id} commit-highlight${ - branchPos.get(commit.branch).index % THEME_COLOR_LIMIT - } ${typeClass}-outer` - ); - gBullets - .append('rect') - .attr('x', x - 6) - .attr('y', y - 6) - .attr('height', 12) - .attr('width', 12) - .attr( - 'class', - `commit ${commit.id} commit${ - branchPos.get(commit.branch).index % THEME_COLOR_LIMIT - } ${typeClass}-inner` - ); - } else if (commitSymbolType === commitType.CHERRY_PICK) { - gBullets - .append('circle') - .attr('cx', x) - .attr('cy', y) - .attr('r', 10) - .attr('class', `commit ${commit.id} ${typeClass}`); - gBullets - .append('circle') - .attr('cx', x - 3) - .attr('cy', y + 2) - .attr('r', 2.75) - .attr('fill', '#fff') - .attr('class', `commit ${commit.id} ${typeClass}`); - gBullets - .append('circle') - .attr('cx', x + 3) - .attr('cy', y + 2) - .attr('r', 2.75) - .attr('fill', '#fff') - .attr('class', `commit ${commit.id} ${typeClass}`); - gBullets - .append('line') - .attr('x1', x + 3) - .attr('y1', y + 1) - .attr('x2', x) - .attr('y2', y - 5) - .attr('stroke', '#fff') - .attr('class', `commit ${commit.id} ${typeClass}`); - gBullets - .append('line') - .attr('x1', x - 3) - .attr('y1', y + 1) - .attr('x2', x) - .attr('y2', y - 5) - .attr('stroke', '#fff') - .attr('class', `commit ${commit.id} ${typeClass}`); - } else { - const circle = gBullets.append('circle'); - circle.attr('cx', x); - circle.attr('cy', y); - circle.attr('r', commit.type === commitType.MERGE ? 9 : 10); - circle.attr( - 'class', - `commit ${commit.id} commit${branchPos.get(commit.branch).index % THEME_COLOR_LIMIT}` - ); - if (commitSymbolType === commitType.MERGE) { - const circle2 = gBullets.append('circle'); - circle2.attr('cx', x); - circle2.attr('cy', y); - circle2.attr('r', 6); - circle2.attr( - 'class', - `commit ${typeClass} ${commit.id} commit${ - branchPos.get(commit.branch).index % THEME_COLOR_LIMIT - }` - ); - } - if (commitSymbolType === commitType.REVERSE) { - const cross = gBullets.append('path'); - cross - .attr('d', `M ${x - 5},${y - 5}L${x + 5},${y + 5}M${x - 5},${y + 5}L${x + 5},${y - 5}`) - .attr( - 'class', - `commit ${typeClass} ${commit.id} commit${ - branchPos.get(commit.branch).index % THEME_COLOR_LIMIT - }` - ); - } - } - } - if (dir === 'TB' || dir === 'BT') { - commitPos.set(commit.id, { x: x, y: posWithOffset }); - } else { - commitPos.set(commit.id, { x: posWithOffset, y: y }); - } - - // The first iteration over the commits are for positioning purposes, this - // is required for drawing the lines. The circles and labels is drawn after the labels - // placing them on top of the lines. - if (modifyGraph) { - const px = 4; - const py = 2; - // Draw the commit label - if ( - commit.type !== commitType.CHERRY_PICK && - ((commit.customId && commit.type === commitType.MERGE) || - commit.type !== commitType.MERGE) && - gitGraphConfig.showCommitLabel - ) { - const wrapper = gLabels.append('g'); - const labelBkg = wrapper.insert('rect').attr('class', 'commit-label-bkg'); - - const text = wrapper - .append('text') - .attr('x', pos) - .attr('y', y + 25) - .attr('class', 'commit-label') - .text(commit.id); - let bbox = text.node().getBBox(); - - // Now we have the label, lets position the background - labelBkg - .attr('x', posWithOffset - bbox.width / 2 - py) - .attr('y', y + 13.5) - .attr('width', bbox.width + 2 * py) - .attr('height', bbox.height + 2 * py); - - if (dir === 'TB' || dir === 'BT') { - labelBkg.attr('x', x - (bbox.width + 4 * px + 5)).attr('y', y - 12); - text.attr('x', x - (bbox.width + 4 * px)).attr('y', y + bbox.height - 12); - } else { - text.attr('x', posWithOffset - bbox.width / 2); - } - if (gitGraphConfig.rotateCommitLabel) { - if (dir === 'TB' || dir === 'BT') { - text.attr('transform', 'rotate(' + -45 + ', ' + x + ', ' + y + ')'); - labelBkg.attr('transform', 'rotate(' + -45 + ', ' + x + ', ' + y + ')'); - } else { - let r_x = -7.5 - ((bbox.width + 10) / 25) * 9.5; - let r_y = 10 + (bbox.width / 25) * 8.5; - wrapper.attr( - 'transform', - 'translate(' + r_x + ', ' + r_y + ') rotate(' + -45 + ', ' + pos + ', ' + y + ')' - ); - } - } - } - if (commit.tags.length > 0) { - let yOffset = 0; - let maxTagBboxWidth = 0; - let maxTagBboxHeight = 0; - const tagElements = []; - - for (const tagValue of commit.tags.reverse()) { - const rect = gLabels.insert('polygon'); - const hole = gLabels.append('circle'); - const tag = gLabels - .append('text') - // Note that we are delaying setting the x position until we know the width of the text - .attr('y', y - 16 - yOffset) - .attr('class', 'tag-label') - .text(tagValue); - let tagBbox = tag.node().getBBox(); - maxTagBboxWidth = Math.max(maxTagBboxWidth, tagBbox.width); - maxTagBboxHeight = Math.max(maxTagBboxHeight, tagBbox.height); - - // We don't use the max over here to center the text within the tags - tag.attr('x', posWithOffset - tagBbox.width / 2); - - tagElements.push({ - tag, - hole, - rect, - yOffset, - }); - - yOffset += 20; - } - - for (const { tag, hole, rect, yOffset } of tagElements) { - const h2 = maxTagBboxHeight / 2; - const ly = y - 19.2 - yOffset; - rect.attr('class', 'tag-label-bkg').attr( - 'points', - ` - ${pos - maxTagBboxWidth / 2 - px / 2},${ly + py} - ${pos - maxTagBboxWidth / 2 - px / 2},${ly - py} - ${posWithOffset - maxTagBboxWidth / 2 - px},${ly - h2 - py} - ${posWithOffset + maxTagBboxWidth / 2 + px},${ly - h2 - py} - ${posWithOffset + maxTagBboxWidth / 2 + px},${ly + h2 + py} - ${posWithOffset - maxTagBboxWidth / 2 - px},${ly + h2 + py}` - ); - - hole - .attr('cy', ly) - .attr('cx', pos - maxTagBboxWidth / 2 + px / 2) - .attr('r', 1.5) - .attr('class', 'tag-hole'); - - if (dir === 'TB' || dir === 'BT') { - const yOrigin = pos + yOffset; - - rect - .attr('class', 'tag-label-bkg') - .attr( - 'points', - ` - ${x},${yOrigin + py} - ${x},${yOrigin - py} - ${x + layoutOffset},${yOrigin - h2 - py} - ${x + layoutOffset + maxTagBboxWidth + px},${yOrigin - h2 - py} - ${x + layoutOffset + maxTagBboxWidth + px},${yOrigin + h2 + py} - ${x + layoutOffset},${yOrigin + h2 + py}` - ) - .attr('transform', 'translate(12,12) rotate(45, ' + x + ',' + pos + ')'); - hole - .attr('cx', x + px / 2) - .attr('cy', yOrigin) - .attr('transform', 'translate(12,12) rotate(45, ' + x + ',' + pos + ')'); - tag - .attr('x', x + 5) - .attr('y', yOrigin + 3) - .attr('transform', 'translate(14,14) rotate(45, ' + x + ',' + pos + ')'); - } - } - } - } - pos = dir === 'BT' && isParallelCommits ? pos + commitStep : pos + commitStep + layoutOffset; - if (pos > maxPos) { - maxPos = pos; - } - }); -}; - -/** - * Detect if there are commits - * between commitA's x-position - * and commitB's x-position on the - * same branch as commitA, where - * commitA isn't main - * - * @param {any} commitA - * @param {any} commitB - * @param p1 - * @param p2 - * @param {CommitMap} allCommits - * @returns {boolean} - * If there are commits between - * commitA's x-position - * and commitB's x-position - * on the source branch, where - * source branch is not main - * return true - */ -const shouldRerouteArrow = (commitA, commitB, p1, p2, allCommits) => { - const commitBIsFurthest = dir === 'TB' || dir === 'BT' ? p1.x < p2.x : p1.y < p2.y; - const branchToGetCurve = commitBIsFurthest ? commitB.branch : commitA.branch; - const isOnBranchToGetCurve = (x) => x.branch === branchToGetCurve; - const isBetweenCommits = (x) => x.seq > commitA.seq && x.seq < commitB.seq; - return [...allCommits.values()].some((commitX) => { - return isBetweenCommits(commitX) && isOnBranchToGetCurve(commitX); - }); -}; - -/** - * This function find a lane in the y-axis that is not overlapping with any other lanes. This is - * used for drawing the lines between commits. - * - * @param {any} y1 - * @param {any} y2 - * @param {any} depth - * @returns {number} Y value between y1 and y2 - */ -const findLane = (y1, y2, depth = 0) => { - const candidate = y1 + Math.abs(y1 - y2) / 2; - if (depth > 5) { - return candidate; - } - - let ok = lanes.every((lane) => Math.abs(lane - candidate) >= 10); - if (ok) { - lanes.push(candidate); - return candidate; - } - const diff = Math.abs(y1 - y2); - return findLane(y1, y2 - diff / 5, depth + 1); -}; - -/** - * Draw the lines between the commits. They were arrows initially. - * - * @param {any} svg - * @param {any} commitA - * @param {any} commitB - * @param {CommitMap} allCommits - */ -const drawArrow = (svg, commitA, commitB, allCommits) => { - const p1 = commitPos.get(commitA.id); // arrowStart - const p2 = commitPos.get(commitB.id); // arrowEnd - const arrowNeedsRerouting = shouldRerouteArrow(commitA, commitB, p1, p2, allCommits); - // log.debug('drawArrow', p1, p2, arrowNeedsRerouting, commitA.id, commitB.id); - - // Lower-right quadrant logic; top-left is 0,0 - - let arc = ''; - let arc2 = ''; - let radius = 0; - let offset = 0; - let colorClassNum = branchPos.get(commitB.branch).index; - if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) { - colorClassNum = branchPos.get(commitA.branch).index; - } - - let lineDef; - if (arrowNeedsRerouting) { - arc = 'A 10 10, 0, 0, 0,'; - arc2 = 'A 10 10, 0, 0, 1,'; - radius = 10; - offset = 10; - - const lineY = p1.y < p2.y ? findLane(p1.y, p2.y) : findLane(p2.y, p1.y); - const lineX = p1.x < p2.x ? findLane(p1.x, p2.x) : findLane(p2.x, p1.x); - - if (dir === 'TB') { - if (p1.x < p2.x) { - // Source commit is on branch position left of destination commit - // so render arrow rightward with colour of destination branch - lineDef = `M ${p1.x} ${p1.y} L ${lineX - radius} ${p1.y} ${arc2} ${lineX} ${ - p1.y + offset - } L ${lineX} ${p2.y - radius} ${arc} ${lineX + offset} ${p2.y} L ${p2.x} ${p2.y}`; - } else { - // Source commit is on branch position right of destination commit - // so render arrow leftward with colour of source branch - colorClassNum = branchPos.get(commitA.branch).index; - lineDef = `M ${p1.x} ${p1.y} L ${lineX + radius} ${p1.y} ${arc} ${lineX} ${ - p1.y + offset - } L ${lineX} ${p2.y - radius} ${arc2} ${lineX - offset} ${p2.y} L ${p2.x} ${p2.y}`; - } - } else if (dir === 'BT') { - if (p1.x < p2.x) { - // Source commit is on branch position left of destination commit - // so render arrow rightward with colour of destination branch - lineDef = `M ${p1.x} ${p1.y} L ${lineX - radius} ${p1.y} ${arc} ${lineX} ${ - p1.y - offset - } L ${lineX} ${p2.y + radius} ${arc2} ${lineX + offset} ${p2.y} L ${p2.x} ${p2.y}`; - } else { - // Source commit is on branch position right of destination commit - // so render arrow leftward with colour of source branch - colorClassNum = branchPos.get(commitA.branch).index; - lineDef = `M ${p1.x} ${p1.y} L ${lineX + radius} ${p1.y} ${arc2} ${lineX} ${ - p1.y - offset - } L ${lineX} ${p2.y + radius} ${arc} ${lineX - offset} ${p2.y} L ${p2.x} ${p2.y}`; - } - } else { - if (p1.y < p2.y) { - // Source commit is on branch positioned above destination commit - // so render arrow downward with colour of destination branch - lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${lineY - radius} ${arc} ${ - p1.x + offset - } ${lineY} L ${p2.x - radius} ${lineY} ${arc2} ${p2.x} ${lineY + offset} L ${p2.x} ${p2.y}`; - } else { - // Source commit is on branch positioned below destination commit - // so render arrow upward with colour of source branch - colorClassNum = branchPos.get(commitA.branch).index; - lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${lineY + radius} ${arc2} ${ - p1.x + offset - } ${lineY} L ${p2.x - radius} ${lineY} ${arc} ${p2.x} ${lineY - offset} L ${p2.x} ${p2.y}`; - } - } - } else { - arc = 'A 20 20, 0, 0, 0,'; - arc2 = 'A 20 20, 0, 0, 1,'; - radius = 20; - offset = 20; - - if (dir === 'TB') { - if (p1.x < p2.x) { - if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) { - lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y - radius} ${arc} ${p1.x + offset} ${ - p2.y - } L ${p2.x} ${p2.y}`; - } else { - lineDef = `M ${p1.x} ${p1.y} L ${p2.x - radius} ${p1.y} ${arc2} ${p2.x} ${ - p1.y + offset - } L ${p2.x} ${p2.y}`; - } - } - if (p1.x > p2.x) { - arc = 'A 20 20, 0, 0, 0,'; - arc2 = 'A 20 20, 0, 0, 1,'; - radius = 20; - offset = 20; - if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) { - lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y - radius} ${arc2} ${p1.x - offset} ${ - p2.y - } L ${p2.x} ${p2.y}`; - } else { - lineDef = `M ${p1.x} ${p1.y} L ${p2.x + radius} ${p1.y} ${arc} ${p2.x} ${ - p1.y + offset - } L ${p2.x} ${p2.y}`; - } - } - - if (p1.x === p2.x) { - lineDef = `M ${p1.x} ${p1.y} L ${p2.x} ${p2.y}`; - } - } else if (dir === 'BT') { - if (p1.x < p2.x) { - if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) { - lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y + radius} ${arc2} ${p1.x + offset} ${ - p2.y - } L ${p2.x} ${p2.y}`; - } else { - lineDef = `M ${p1.x} ${p1.y} L ${p2.x - radius} ${p1.y} ${arc} ${p2.x} ${ - p1.y - offset - } L ${p2.x} ${p2.y}`; - } - } - if (p1.x > p2.x) { - arc = 'A 20 20, 0, 0, 0,'; - arc2 = 'A 20 20, 0, 0, 1,'; - radius = 20; - offset = 20; - - if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) { - lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y + radius} ${arc} ${p1.x - offset} ${ - p2.y - } L ${p2.x} ${p2.y}`; - } else { - lineDef = `M ${p1.x} ${p1.y} L ${p2.x - radius} ${p1.y} ${arc} ${p2.x} ${ - p1.y - offset - } L ${p2.x} ${p2.y}`; - } - } - - if (p1.x === p2.x) { - lineDef = `M ${p1.x} ${p1.y} L ${p2.x} ${p2.y}`; - } - } else { - if (p1.y < p2.y) { - if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) { - lineDef = `M ${p1.x} ${p1.y} L ${p2.x - radius} ${p1.y} ${arc2} ${p2.x} ${ - p1.y + offset - } L ${p2.x} ${p2.y}`; - } else { - lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y - radius} ${arc} ${p1.x + offset} ${ - p2.y - } L ${p2.x} ${p2.y}`; - } - } - if (p1.y > p2.y) { - if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) { - lineDef = `M ${p1.x} ${p1.y} L ${p2.x - radius} ${p1.y} ${arc} ${p2.x} ${ - p1.y - offset - } L ${p2.x} ${p2.y}`; - } else { - lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y + radius} ${arc2} ${p1.x + offset} ${ - p2.y - } L ${p2.x} ${p2.y}`; - } - } - - if (p1.y === p2.y) { - lineDef = `M ${p1.x} ${p1.y} L ${p2.x} ${p2.y}`; - } - } - } - svg - .append('path') - .attr('d', lineDef) - .attr('class', 'arrow arrow' + (colorClassNum % THEME_COLOR_LIMIT)); -}; - -/** - * @param {*} svg - * @param {CommitMap} commits - */ -const drawArrows = (svg, commits) => { - const gArrows = svg.append('g').attr('class', 'commit-arrows'); - [...commits.keys()].forEach((key) => { - const commit = commits.get(key); - if (commit.parents && commit.parents.length > 0) { - commit.parents.forEach((parent) => { - drawArrow(gArrows, commits.get(parent), commit, commits); - }); - } - }); -}; - -/** - * Adds the branches and the branches' labels to the svg. - * - * @param svg - * @param branches - */ -const drawBranches = (svg, branches) => { - const gitGraphConfig = getConfig().gitGraph; - const g = svg.append('g'); - branches.forEach((branch, index) => { - const adjustIndexForTheme = index % THEME_COLOR_LIMIT; - - const pos = branchPos.get(branch.name).pos; - const line = g.append('line'); - line.attr('x1', 0); - line.attr('y1', pos); - line.attr('x2', maxPos); - line.attr('y2', pos); - line.attr('class', 'branch branch' + adjustIndexForTheme); - - if (dir === 'TB') { - line.attr('y1', defaultPos); - line.attr('x1', pos); - line.attr('y2', maxPos); - line.attr('x2', pos); - } else if (dir === 'BT') { - line.attr('y1', maxPos); - line.attr('x1', pos); - line.attr('y2', defaultPos); - line.attr('x2', pos); - } - lanes.push(pos); - - let name = branch.name; - - // Create the actual text element - const labelElement = drawText(name); - // Create outer g, edgeLabel, this will be positioned after graph layout - const bkg = g.insert('rect'); - const branchLabel = g.insert('g').attr('class', 'branchLabel'); - - // Create inner g, label, this will be positioned now for centering the text - const label = branchLabel.insert('g').attr('class', 'label branch-label' + adjustIndexForTheme); - label.node().appendChild(labelElement); - let bbox = labelElement.getBBox(); - bkg - .attr('class', 'branchLabelBkg label' + adjustIndexForTheme) - .attr('rx', 4) - .attr('ry', 4) - .attr('x', -bbox.width - 4 - (gitGraphConfig.rotateCommitLabel === true ? 30 : 0)) - .attr('y', -bbox.height / 2 + 8) - .attr('width', bbox.width + 18) - .attr('height', bbox.height + 4); - label.attr( - 'transform', - 'translate(' + - (-bbox.width - 14 - (gitGraphConfig.rotateCommitLabel === true ? 30 : 0)) + - ', ' + - (pos - bbox.height / 2 - 1) + - ')' - ); - if (dir === 'TB') { - bkg.attr('x', pos - bbox.width / 2 - 10).attr('y', 0); - label.attr('transform', 'translate(' + (pos - bbox.width / 2 - 5) + ', ' + 0 + ')'); - } else if (dir === 'BT') { - bkg.attr('x', pos - bbox.width / 2 - 10).attr('y', maxPos); - label.attr('transform', 'translate(' + (pos - bbox.width / 2 - 5) + ', ' + maxPos + ')'); - } else { - bkg.attr('transform', 'translate(' + -19 + ', ' + (pos - bbox.height / 2) + ')'); - } - }); -}; - -/** - * @param txt - * @param id - * @param ver - * @param diagObj - */ -export const draw = function (txt, id, ver, diagObj) { - clear(); - const conf = getConfig(); - const gitGraphConfig = conf.gitGraph; - // try { - log.debug('in gitgraph renderer', txt + '\n', 'id:', id, ver); - - allCommitsDict = diagObj.db.getCommits(); - const branches = diagObj.db.getBranchesAsObjArray(); - dir = diagObj.db.getDirection(); - const diagram = select(`[id="${id}"]`); - // Position branches - let pos = 0; - branches.forEach((branch, index) => { - const labelElement = drawText(branch.name); - const g = diagram.append('g'); - const branchLabel = g.insert('g').attr('class', 'branchLabel'); - const label = branchLabel.insert('g').attr('class', 'label branch-label'); - label.node().appendChild(labelElement); - let bbox = labelElement.getBBox(); - - branchPos.set(branch.name, { pos, index }); - pos += - 50 + - (gitGraphConfig.rotateCommitLabel ? 40 : 0) + - (dir === 'TB' || dir === 'BT' ? bbox.width / 2 : 0); - label.remove(); - branchLabel.remove(); - g.remove(); - }); - - drawCommits(diagram, allCommitsDict, false); - if (gitGraphConfig.showBranches) { - drawBranches(diagram, branches); - } - drawArrows(diagram, allCommitsDict); - drawCommits(diagram, allCommitsDict, true); - utils.insertTitle( - diagram, - 'gitTitleText', - gitGraphConfig.titleTopMargin, - diagObj.db.getDiagramTitle() - ); - - // Setup the view box and size of the svg element - setupGraphViewbox( - undefined, - diagram, - gitGraphConfig.diagramPadding, - gitGraphConfig.useMaxWidth ?? conf.useMaxWidth - ); -}; - -export default { - draw, -}; diff --git a/packages/mermaid/src/diagrams/git/gitGraphRenderer.ts b/packages/mermaid/src/diagrams/git/gitGraphRenderer.ts new file mode 100644 index 000000000..ec9a39f2a --- /dev/null +++ b/packages/mermaid/src/diagrams/git/gitGraphRenderer.ts @@ -0,0 +1,968 @@ +import { select } from 'd3'; +import { getConfig, setupGraphViewbox } from '../../diagram-api/diagramAPI.js'; +import { log } from '../../logger.js'; +import utils from '../../utils.js'; +import type { DrawDefinition } from '../../diagram-api/types.js'; +import type d3 from 'd3'; +import type { CommitType, Commit, GitGraphDB, DiagramOrientation } from './gitGraphTypes.js'; +import type { GitGraphDiagramConfig } from '../../config.type.js'; + +let allCommitsDict = new Map(); + +const commitType: CommitType = { + NORMAL: 0, + REVERSE: 1, + HIGHLIGHT: 2, + MERGE: 3, + CHERRY_PICK: 4, +}; + +const THEME_COLOR_LIMIT = 8; + +interface BranchPosition { + pos: number; + index: number; +} + +interface CommitPosition { + x: number; + y: number; +} + +const branchPos = new Map(); +const commitPos = new Map(); +let lanes: number[] = []; +let maxPos = 0; +let dir: DiagramOrientation = 'LR'; +const defaultPos = 30; + +const clear = () => { + branchPos.clear(); + commitPos.clear(); + allCommitsDict.clear(); + maxPos = 0; + lanes = []; + dir = 'LR'; +}; + +const drawText = (txt: string | string[]) => { + const svgLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + const rows = typeof txt === 'string' ? txt.split(/\\n|\n|/gi) : txt; + + rows.forEach((row) => { + const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan'); + tspan.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve'); + tspan.setAttribute('dy', '1em'); + tspan.setAttribute('x', '0'); + tspan.setAttribute('class', 'row'); + tspan.textContent = row.trim(); + svgLabel.appendChild(tspan); + }); + + return svgLabel; +}; + +const findClosestParent = (parents: string[], useBTLogic = false): string | undefined => { + let closestParent: string | undefined; + let comparisonFunc; + let targetPosition: number; + + if (dir === 'BT' || useBTLogic) { + comparisonFunc = (a: number, b: number) => a <= b; + targetPosition = Infinity; + } else { + comparisonFunc = (a: number, b: number) => a >= b; + targetPosition = 0; + } + + parents.forEach((parent) => { + const parentPosition = + dir === 'TB' || dir == 'BT' || useBTLogic + ? commitPos.get(parent)?.y + : commitPos.get(parent)?.x; + + if (parentPosition !== undefined && comparisonFunc(parentPosition, targetPosition)) { + closestParent = parent; + targetPosition = parentPosition; + } + }); + + return closestParent; +}; + +const setParallelBTPos = ( + sortedKeys: string[], + commits: Map, + defaultPos: number, + commitStep: number, + layoutOffset: number +) => { + let curPos = defaultPos; + let maxPosition = defaultPos; + const roots: Commit[] = []; + + sortedKeys.forEach((key) => { + const commit = commits.get(key); + if (!commit) { + throw new Error(`Commit not found for key ${key}`); + } + + if (hasParents(commit)) { + curPos = calculateCommitPosition(commit, commitStep, maxPosition); + maxPosition = Math.max(curPos, maxPosition); + } else { + roots.push(commit); + } + setCommitPosition(commit, curPos, layoutOffset); + }); + + curPos = maxPosition; + roots.forEach((commit) => { + setRootPosition(commit, curPos, defaultPos); + }); +}; + +const hasParents = (commit: Commit): boolean => commit.parents?.length > 0; + +const findClosestParentPos = (commit: Commit): number => { + const closestParent = findClosestParent(commit.parents.filter((p) => p !== null) as string[]); + if (!closestParent) { + throw new Error(`Closest parent not found for commit ${commit.id}`); + } + + const closestParentPos = commitPos.get(closestParent)?.y; + if (closestParentPos === undefined) { + throw new Error(`Closest parent position not found for commit ${commit.id}`); + } + return closestParentPos; +}; + +const calculateCommitPosition = (commit: Commit, commitStep: number): number => { + const closestParentPos = findClosestParentPos(commit); + return closestParentPos + commitStep; +}; + +const setCommitPosition = (commit: Commit, curPos: number, layoutOffset: number) => { + const branch = branchPos.get(commit.branch); + if (!branch) { + throw new Error(`Branch not found for commit ${commit.id}`); + } + + const x = branch.pos; + const y = curPos + layoutOffset; + commitPos.set(commit.id, { x, y }); +}; + +const setRootPosition = (commit: Commit, curPos: number, defaultPos: number) => { + const branch = branchPos.get(commit.branch); + if (!branch) { + throw new Error(`Branch not found for commit ${commit.id}`); + } + + const y = curPos + defaultPos; + const x = branch.pos; + commitPos.set(commit.id, { x, y }); +}; + +const drawCommitBullet = ( + gBullets: d3.Selection, + commit: Commit, + x: number, + y: number, + typeClass: string, + branchIndex: number, + commitSymbolType: number +) => { + if (commitSymbolType === commitType.HIGHLIGHT) { + gBullets + .append('rect') + .attr('x', x - 10) + .attr('y', y - 10) + .attr('width', 20) + .attr('height', 20) + .attr( + 'class', + `commit ${commit.id} commit-highlights${branchIndex % THEME_COLOR_LIMIT} ${typeClass}-outer` + ); + gBullets + .append('rect') + .attr('x', x - 6) + .attr('y', y - 6) + .attr('width', 12) + .attr('height', 12) + .attr( + 'class', + `commit ${commit.id} commit${branchIndex % THEME_COLOR_LIMIT} ${typeClass}-inner` + ); + } else if (commitSymbolType === commitType.CHERRY_PICK) { + gBullets + .append('circle') + .attr('cx', x) + .attr('cy', y) + .attr('r', 10) + .attr('class', `commit ${commit.id} ${typeClass}`); + gBullets + .append('circle') + .attr('cx', x - 3) + .attr('cy', y + 2) + .attr('r', 2.75) + .attr('fill', '#fff') + .attr('class', `commit ${commit.id} ${typeClass}`); + gBullets + .append('circle') + .attr('cx', x + 3) + .attr('cy', y + 2) + .attr('r', 2.75) + .attr('fill', '#fff') + .attr('class', `commit ${commit.id} ${typeClass}`); + gBullets + .append('line') + .attr('x1', x + 3) + .attr('y1', y + 1) + .attr('x2', x) + .attr('y2', y - 5) + .attr('stroke', '#fff') + .attr('class', `commit ${commit.id} ${typeClass}`); + gBullets + .append('line') + .attr('x1', x - 3) + .attr('y1', y + 1) + .attr('x2', x) + .attr('y2', y - 5) + .attr('stroke', '#fff') + .attr('class', `commit ${commit.id} ${typeClass}`); + } else { + const circle = gBullets.append('circle'); + circle.attr('cx', x); + circle.attr('cy', y); + circle.attr('r', commit.type === commitType.MERGE ? 9 : 10); + circle.attr('class', `commit ${commit.id} commit${branchIndex % THEME_COLOR_LIMIT}`); + if (commit.type === commitType.MERGE) { + const circle2 = gBullets.append('circle'); + circle2.attr('cx', x); + circle2.attr('cy', y); + circle2.attr('r', 6); + circle2.attr( + 'class', + `commit ${typeClass} ${commit.id} commit${branchIndex % THEME_COLOR_LIMIT}` + ); + } + if (commitSymbolType === commitType.REVERSE) { + const cross = gBullets.append('path'); + cross + .attr('d', `M ${x - 5},${y - 5}L${x + 5},${y + 5}M${x - 5},${y + 5}L${x + 5},${y - 5}`) + .attr('class', `commit ${typeClass} ${commit.id} commit${branchIndex % THEME_COLOR_LIMIT}`); + } + } +}; + +const drawCommitLabel = ( + gLabels: d3.Selection, + commit: Commit, + x: number, + y: number, + pos: number, + posWithOffset: number, + gitGraphConfig: GitGraphDiagramConfig +) => { + if ( + commit.type !== commitType.CHERRY_PICK && + ((commit.customId && commit.type === commitType.MERGE) || commit.type !== commitType.MERGE) && + gitGraphConfig.showCommitLabel + ) { + const wrapper = gLabels.append('g'); + const labelBkg = wrapper.insert('rect').attr('class', 'commit-label-bkg'); + const text = wrapper + .append('text') + .attr('x', x) + .attr('y', y + 25) + .attr('class', 'commit-label') + .text(commit.id); + const bbox = text.node()?.getBBox(); + + if (bbox) { + labelBkg + .attr('x', posWithOffset - bbox.width / 2 - 2) + .attr('y', y + 13.5) + .attr('width', bbox.width + 4) + .attr('height', bbox.height + 4); + + if (dir === 'TB' || dir === 'BT') { + labelBkg.attr('x', x - (bbox.width + 4)).attr('y', y - 12); + text.attr('x', x - (bbox.width + 2)).attr('y', y + bbox.height - 12); + } + + if (gitGraphConfig.rotateCommitLabel) { + if (dir === 'TB' || dir === 'BT') { + text.attr('transform', 'rotate(' + -45 + ', ' + x + ', ' + y + ')'); + labelBkg.attr('transform', 'rotate(' + -45 + ', ' + x + ', ' + y + ')'); + } else { + const r_x = -7.5 - ((bbox.width + 10) / 25) * 9.5; + const r_y = 10 + (bbox.width / 25) * 8.5; + wrapper.attr( + 'transform', + 'translate(' + r_x + ', ' + r_y + ') rotate(' + -45 + ', ' + pos + ', ' + y + ')' + ); + } + } + } + } +}; + +const drawCommitTags = ( + gLabels: d3.Selection, + commit: Commit, + x: number, + y: number, + pos: number, + posWithOffset: number, + layoutOffset: number +) => { + if (commit.tags.length > 0) { + let yOffset = 0; + let maxTagBboxWidth = 0; + let maxTagBboxHeight = 0; + const tagElements = []; + + for (const tagValue of commit.tags.reverse()) { + const rect = gLabels.insert('polygon'); + const hole = gLabels.append('circle'); + const tag = gLabels + .append('text') + .attr('y', y - 16 - yOffset) + .attr('class', 'tag-label') + .text(tagValue); + const tagBbox = tag.node()?.getBBox(); + if (!tagBbox) { + throw new Error('Tag bbox not found'); + } + maxTagBboxWidth = Math.max(maxTagBboxWidth, tagBbox.width); + maxTagBboxHeight = Math.max(maxTagBboxHeight, tagBbox.height); + + tag.attr('x', posWithOffset - tagBbox.width / 2); + + tagElements.push({ + tag, + hole, + rect, + yOffset, + }); + + yOffset += 20; + } + + for (const { tag, hole, rect, yOffset } of tagElements) { + const h2 = maxTagBboxHeight / 2; + const ly = y - 19.2 - yOffset; + rect.attr('class', 'tag-label-bkg').attr( + 'points', + ` + ${pos - maxTagBboxWidth / 2 - 2},${ly + 2} + ${pos - maxTagBboxWidth / 2 - 2},${ly - 2} + ${posWithOffset - maxTagBboxWidth / 2 - 4},${ly - h2 - 2} + ${posWithOffset + maxTagBboxWidth / 2 + 4},${ly - h2 - 2} + ${posWithOffset + maxTagBboxWidth / 2 + 4},${ly + h2 + 2} + ${posWithOffset - maxTagBboxWidth / 2 - 4},${ly + h2 + 2}` + ); + + hole + .attr('cy', ly) + .attr('cx', pos - maxTagBboxWidth / 2 + 2) + .attr('r', 1.5) + .attr('class', 'tag-hole'); + + if (dir === 'TB' || dir === 'BT') { + const yOrigin = pos + yOffset; + + rect + .attr('class', 'tag-label-bkg') + .attr( + 'points', + ` + ${x},${yOrigin + 2} + ${x},${yOrigin - 2} + ${x + layoutOffset},${yOrigin - h2 - 2} + ${x + layoutOffset + maxTagBboxWidth + 4},${yOrigin - h2 - 2} + ${x + layoutOffset + maxTagBboxWidth + 4},${yOrigin + h2 + 2} + ${x + layoutOffset},${yOrigin + h2 + 2}` + ) + .attr('transform', 'translate(12,12) rotate(45, ' + x + ',' + pos + ')'); + hole + .attr('cx', x + 2) + .attr('cy', yOrigin) + .attr('transform', 'translate(12,12) rotate(45, ' + x + ',' + pos + ')'); + tag + .attr('x', x + 5) + .attr('y', yOrigin + 3) + .attr('transform', 'translate(14,14) rotate(45, ' + x + ',' + pos + ')'); + } + } + } +}; + +const getCommitClassType = (commit: Commit): string => { + const commitSymbolType = commit.customType ?? commit.type; + switch (commitSymbolType) { + case commitType.NORMAL: + return 'commit-normal'; + case commitType.REVERSE: + return 'commit-reverse'; + case commitType.HIGHLIGHT: + return 'commit-highlight'; + case commitType.MERGE: + return 'commit-merge'; + case commitType.CHERRY_PICK: + return 'commit-cherry-pick'; + default: + return 'commit-normal'; + } +}; + +const calculatePosition = ( + commit: Commit, + dir: string, + isParallelCommits: boolean, + pos: number, + commitStep: number, + layoutOffset: number, + commitPos: Map +): number => { + const defaultCommitPosition = { x: 0, y: defaultPos }; // Default position if commit is not found + + if (isParallelCommits) { + if (commit.parents.length > 0) { + const closestParent = + dir === 'BT' ? findClosestParent(commit.parents) : findClosestParent(commit.parents); + + // Check if closestParent is defined + if (closestParent) { + const parentPosition = commitPos.get(closestParent) ?? defaultCommitPosition; + + if (dir === 'TB') { + return parentPosition.y + commitStep; + } else if (dir === 'BT') { + const currentPosition = commitPos.get(commit.id) ?? defaultCommitPosition; + return currentPosition.y - commitStep; + } else { + return parentPosition.x + commitStep; + } + } + } else { + if (dir === 'TB') { + return defaultPos; + } else if (dir === 'BT') { + const currentPosition = commitPos.get(commit.id) ?? defaultCommitPosition; + return currentPosition.y - commitStep; + } else { + return 0; + } + } + } + + return dir === 'TB' && isParallelCommits ? pos : pos + layoutOffset; +}; + +const drawCommits = ( + svg: d3.Selection, + commits: Map, + modifyGraph: boolean +) => { + const gitGraphConfig = getConfig().gitGraph; + if (!gitGraphConfig) { + throw new Error('GitGraph config not found'); + } + const gBullets = svg.append('g').attr('class', 'commit-bullets'); + const gLabels = svg.append('g').attr('class', 'commit-labels'); + let pos = dir === 'TB' || dir === 'BT' ? defaultPos : 0; + const keys = [...commits.keys()]; + const isParallelCommits = gitGraphConfig?.parallelCommits ?? false; + const layoutOffset = 10; + const commitStep = 40; + + const sortKeys = (a: string, b: string) => { + const seqA = commits.get(a)?.seq; + const seqB = commits.get(b)?.seq; + return seqA !== undefined && seqB !== undefined ? seqA - seqB : 0; + }; + + let sortedKeys = keys.sort(sortKeys); + + if (dir === 'BT' && !isParallelCommits) { + sortedKeys = sortedKeys.reverse(); + } + + if (dir === 'BT' && isParallelCommits) { + setParallelBTPos(sortedKeys, commits, pos, commitStep, layoutOffset); + sortedKeys = sortedKeys.reverse(); + } + + sortedKeys.forEach((key) => { + const commit = commits.get(key); + if (!commit) { + throw new Error(`Commit not found for key ${key}`); + } else { + pos = calculatePosition( + commit, + dir, + isParallelCommits, + pos, + commitStep, + layoutOffset, + commitPos + ); + const posWithOffset = dir === 'BT' && isParallelCommits ? pos : pos + layoutOffset; + const y = dir === 'TB' || dir === 'BT' ? posWithOffset : branchPos.get(commit.branch)?.pos; + const x = dir === 'TB' || dir === 'BT' ? branchPos.get(commit.branch)?.pos : posWithOffset; + if (x === undefined || y === undefined) { + throw new Error(`Position were undefined for commit ${commit.id}`); + } + // Don't draw the commits now but calculate the positioning which is used by the branch lines etc. + if (modifyGraph) { + const typeClass = getCommitClassType(commit); + const commitSymbolType = commit.customType ?? commit.type; + const branchIndex = branchPos.get(commit.branch)?.index ?? 0; + drawCommitBullet(gBullets, commit, x, y, typeClass, branchIndex, commitSymbolType); + drawCommitLabel(gLabels, commit, x, y, pos, posWithOffset, gitGraphConfig); + drawCommitTags(gLabels, commit, x, y, pos, posWithOffset, layoutOffset); + } + if (dir === 'TB' || dir === 'BT') { + commitPos.set(commit.id, { x: x, y: posWithOffset }); + } else { + commitPos.set(commit.id, { x: posWithOffset, y: y }); + } + pos = dir === 'BT' && isParallelCommits ? pos + commitStep : pos + commitStep + layoutOffset; + if (pos > maxPos) { + maxPos = pos; + } + } + }); +}; + +const shouldRerouteArrow = ( + commitA: Commit, + commitB: Commit, + p1: CommitPosition, + p2: CommitPosition, + allCommits: Map +) => { + const commitBIsFurthest = dir === 'TB' || dir === 'BT' ? p1.x < p2.x : p1.y < p2.y; + const branchToGetCurve = commitBIsFurthest ? commitB.branch : commitA.branch; + const isOnBranchToGetCurve = (x: Commit) => x.branch === branchToGetCurve; + const isBetweenCommits = (x: Commit) => x.seq > commitA.seq && x.seq < commitB.seq; + return [...allCommits.values()].some((commitX) => { + return isBetweenCommits(commitX) && isOnBranchToGetCurve(commitX); + }); +}; + +const findLane = (y1: number, y2: number, depth = 0): number => { + const candidate = y1 + Math.abs(y1 - y2) / 2; + if (depth > 5) { + return candidate; + } + + const ok = lanes.every((lane) => Math.abs(lane - candidate) >= 10); + if (ok) { + lanes.push(candidate); + return candidate; + } + const diff = Math.abs(y1 - y2); + return findLane(y1, y2 - diff / 5, depth + 1); +}; + +const drawArrow = ( + svg: d3.Selection, + commitA: Commit, + commitB: Commit, + allCommits: Map +) => { + const p1 = commitPos.get(commitA.id); // arrowStart + const p2 = commitPos.get(commitB.id); // arrowEnd + // @ts-ignore: TODO Fix ts errors + const arrowNeedsRerouting = shouldRerouteArrow(commitA, commitB, p1, p2, allCommits); + // log.debug('drawArrow', p1, p2, arrowNeedsRerouting, commitA.id, commitB.id); + + // Lower-right quadrant logic; top-left is 0,0 + + let arc = ''; + let arc2 = ''; + let radius = 0; + let offset = 0; + // @ts-ignore: TODO Fix ts errors + let colorClassNum = branchPos.get(commitB.branch).index; + if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) { + // @ts-ignore: TODO Fix ts errors + colorClassNum = branchPos.get(commitA.branch).index; + } + + let lineDef; + if (arrowNeedsRerouting) { + arc = 'A 10 10, 0, 0, 0,'; + arc2 = 'A 10 10, 0, 0, 1,'; + radius = 10; + offset = 10; + // @ts-ignore: TODO Fix ts errors + const lineY = p1.y < p2.y ? findLane(p1.y, p2.y) : findLane(p2.y, p1.y); + // @ts-ignore: TODO Fix ts errors + const lineX = p1.x < p2.x ? findLane(p1.x, p2.x) : findLane(p2.x, p1.x); + + if (dir === 'TB') { + // @ts-ignore: TODO Fix ts errors + if (p1.x < p2.x) { + // Source commit is on branch position left of destination commit + // so render arrow rightward with colour of destination branch + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${lineX - radius} ${p1.y} ${arc2} ${lineX} ${ + // @ts-ignore: TODO Fix ts errors + p1.y + offset + // @ts-ignore: TODO Fix ts errors + } L ${lineX} ${p2.y - radius} ${arc} ${lineX + offset} ${p2.y} L ${p2.x} ${p2.y}`; + } else { + // Source commit is on branch position right of destination commit + // so render arrow leftward with colour of source branch + // @ts-ignore: TODO Fix ts errors + colorClassNum = branchPos.get(commitA.branch).index; + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${lineX + radius} ${p1.y} ${arc} ${lineX} ${p1.y + offset} L ${lineX} ${p2.y - radius} ${arc2} ${lineX - offset} ${p2.y} L ${p2.x} ${p2.y}`; + } + } else if (dir === 'BT') { + // @ts-ignore: TODO Fix ts errors + if (p1.x < p2.x) { + // Source commit is on branch position left of destination commit + // so render arrow rightward with colour of destination branch + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${lineX - radius} ${p1.y} ${arc} ${lineX} ${p1.y - offset} L ${lineX} ${p2.y + radius} ${arc2} ${lineX + offset} ${p2.y} L ${p2.x} ${p2.y}`; + } else { + // Source commit is on branch position right of destination commit + // so render arrow leftward with colour of source branch + // @ts-ignore: TODO Fix ts errors + colorClassNum = branchPos.get(commitA.branch).index; + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${lineX + radius} ${p1.y} ${arc2} ${lineX} ${p1.y - offset} L ${lineX} ${p2.y + radius} ${arc} ${lineX - offset} ${p2.y} L ${p2.x} ${p2.y}`; + } + } else { + // @ts-ignore: TODO Fix ts errors + if (p1.y < p2.y) { + // Source commit is on branch positioned above destination commit + // so render arrow downward with colour of destination branch + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${lineY - radius} ${arc} ${ + // @ts-ignore: TODO Fix ts errors + p1.x + offset + // @ts-ignore: TODO Fix ts errors + } ${lineY} L ${p2.x - radius} ${lineY} ${arc2} ${p2.x} ${lineY + offset} L ${p2.x} ${p2.y}`; + } else { + // Source commit is on branch positioned below destination commit + // so render arrow upward with colour of source branch + // @ts-ignore: TODO Fix ts errors + colorClassNum = branchPos.get(commitA.branch).index; + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${lineY + radius} ${arc2} ${ + // @ts-ignore: TODO Fix ts errors + p1.x + offset + // @ts-ignore: TODO Fix ts errors + } ${lineY} L ${p2.x - radius} ${lineY} ${arc} ${p2.x} ${lineY - offset} L ${p2.x} ${p2.y}`; + } + } + } else { + arc = 'A 20 20, 0, 0, 0,'; + arc2 = 'A 20 20, 0, 0, 1,'; + radius = 20; + offset = 20; + + if (dir === 'TB') { + // @ts-ignore: TODO Fix ts errors + if (p1.x < p2.x) { + if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) { + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y - radius} ${arc} ${p1.x + offset} ${ + // @ts-ignore: TODO Fix ts errors + p2.y + // @ts-ignore: TODO Fix ts errors + } L ${p2.x} ${p2.y}`; + } else { + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${p2.x - radius} ${p1.y} ${arc2} ${p2.x} ${ + // @ts-ignore: TODO Fix ts errors + p1.y + offset + // @ts-ignore: TODO Fix ts errors + } L ${p2.x} ${p2.y}`; + } + } + // @ts-ignore: TODO Fix ts errors + if (p1.x > p2.x) { + arc = 'A 20 20, 0, 0, 0,'; + arc2 = 'A 20 20, 0, 0, 1,'; + radius = 20; + offset = 20; + if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) { + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y - radius} ${arc2} ${p1.x - offset} ${ + // @ts-ignore: TODO Fix ts errors + p2.y + // @ts-ignore: TODO Fix ts errors + } L ${p2.x} ${p2.y}`; + } else { + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${p2.x + radius} ${p1.y} ${arc} ${p2.x} ${ + // @ts-ignore: TODO Fix ts errors + p1.y + offset + // @ts-ignore: TODO Fix ts errors + } L ${p2.x} ${p2.y}`; + } + } + // @ts-ignore: TODO Fix ts errors + if (p1.x === p2.x) { + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${p2.x} ${p2.y}`; + } + } else if (dir === 'BT') { + // @ts-ignore: TODO Fix ts errors + if (p1.x < p2.x) { + if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) { + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y + radius} ${arc2} ${p1.x + offset} ${ + // @ts-ignore: TODO Fix ts errors + p2.y + // @ts-ignore: TODO Fix ts errors + } L ${p2.x} ${p2.y}`; + } else { + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${p2.x - radius} ${p1.y} ${arc} ${p2.x} ${ + // @ts-ignore: TODO Fix ts errors + p1.y - offset + // @ts-ignore: TODO Fix ts errors + } L ${p2.x} ${p2.y}`; + } + } + // @ts-ignore: TODO Fix ts errors + if (p1.x > p2.x) { + arc = 'A 20 20, 0, 0, 0,'; + arc2 = 'A 20 20, 0, 0, 1,'; + radius = 20; + offset = 20; + + if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) { + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y + radius} ${arc} ${p1.x - offset} ${ + // @ts-ignore: TODO Fix ts errors + p2.y + // @ts-ignore: TODO Fix ts errors + } L ${p2.x} ${p2.y}`; + } else { + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${p2.x - radius} ${p1.y} ${arc} ${p2.x} ${ + // @ts-ignore: TODO Fix ts errors + p1.y - offset + // @ts-ignore: TODO Fix ts errors + } L ${p2.x} ${p2.y}`; + } + } + // @ts-ignore: TODO Fix ts errors + if (p1.x === p2.x) { + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${p2.x} ${p2.y}`; + } + } else { + // @ts-ignore: TODO Fix ts errors + if (p1.y < p2.y) { + if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) { + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${p2.x - radius} ${p1.y} ${arc2} ${p2.x} ${ + // @ts-ignore: TODO Fix ts errors + p1.y + offset + // @ts-ignore: TODO Fix ts errors + } L ${p2.x} ${p2.y}`; + } else { + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y - radius} ${arc} ${p1.x + offset} ${ + // @ts-ignore: TODO Fix ts errors + p2.y + // @ts-ignore: TODO Fix ts errors + } L ${p2.x} ${p2.y}`; + } + } // @ts-ignore: TODO Fix ts errors + if (p1.y > p2.y) { + if (commitB.type === commitType.MERGE && commitA.id !== commitB.parents[0]) { + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${p2.x - radius} ${p1.y} ${arc} ${p2.x} ${ + // @ts-ignore: TODO Fix ts errors + p1.y - offset + // @ts-ignore: TODO Fix ts errors + } L ${p2.x} ${p2.y}`; + } else { + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${p1.x} ${p2.y + radius} ${arc2} ${p1.x + offset} ${ + // @ts-ignore: TODO Fix ts errors + p2.y + // @ts-ignore: TODO Fix ts errors + } L ${p2.x} ${p2.y}`; + } + } + // @ts-ignore: TODO Fix ts errors + if (p1.y === p2.y) { + // @ts-ignore: TODO Fix ts errors + lineDef = `M ${p1.x} ${p1.y} L ${p2.x} ${p2.y}`; + } + } + } + svg + .append('path') + // @ts-ignore: TODO Fix ts errors + .attr('d', lineDef) + .attr('class', 'arrow arrow' + (colorClassNum % THEME_COLOR_LIMIT)); +}; + +const drawArrows = ( + svg: d3.Selection, + commits: Map +) => { + const gArrows = svg.append('g').attr('class', 'commit-arrows'); + [...commits.keys()].forEach((key) => { + const commit = commits.get(key); + // @ts-ignore: TODO Fix ts errors + if (commit.parents && commit.parents.length > 0) { + // @ts-ignore: TODO Fix ts errors + commit.parents.forEach((parent) => { + // @ts-ignore: TODO Fix ts errors + drawArrow(gArrows, commits.get(parent), commit, commits); + }); + } + }); +}; + +const drawBranches = ( + svg: d3.Selection, + branches: { name: string }[] +) => { + const gitGraphConfig = getConfig().gitGraph; + const g = svg.append('g'); + branches.forEach((branch, index) => { + // @ts-ignore: TODO Fix ts errors + const adjustIndexForTheme = index % THEME_COLOR_LIMIT; + // @ts-ignore: TODO Fix ts errors + const pos = branchPos.get(branch.name).pos; + const line = g.append('line'); + line.attr('x1', 0); + line.attr('y1', pos); + line.attr('x2', maxPos); + line.attr('y2', pos); + line.attr('class', 'branch branch' + adjustIndexForTheme); + + if (dir === 'TB') { + line.attr('y1', defaultPos); + line.attr('x1', pos); + line.attr('y2', maxPos); + line.attr('x2', pos); + } else if (dir === 'BT') { + line.attr('y1', maxPos); + line.attr('x1', pos); + line.attr('y2', defaultPos); + line.attr('x2', pos); + } + lanes.push(pos); + + const name = branch.name; + + // Create the actual text element + const labelElement = drawText(name); + // Create outer g, edgeLabel, this will be positioned after graph layout + const bkg = g.insert('rect'); + const branchLabel = g.insert('g').attr('class', 'branchLabel'); + + // Create inner g, label, this will be positioned now for centering the text + const label = branchLabel.insert('g').attr('class', 'label branch-label' + adjustIndexForTheme); + // @ts-ignore: TODO Fix ts errors + label.node().appendChild(labelElement); + const bbox = labelElement.getBBox(); + bkg + .attr('class', 'branchLabelBkg label' + adjustIndexForTheme) + .attr('rx', 4) + .attr('ry', 4) + // @ts-ignore: TODO Fix ts errors + .attr('x', -bbox.width - 4 - (gitGraphConfig.rotateCommitLabel === true ? 30 : 0)) + .attr('y', -bbox.height / 2 + 8) + .attr('width', bbox.width + 18) + .attr('height', bbox.height + 4); + label.attr( + 'transform', + 'translate(' + + // @ts-ignore: TODO Fix ts errors + (-bbox.width - 14 - (gitGraphConfig.rotateCommitLabel === true ? 30 : 0)) + + ', ' + + (pos - bbox.height / 2 - 1) + + ')' + ); + if (dir === 'TB') { + bkg.attr('x', pos - bbox.width / 2 - 10).attr('y', 0); + label.attr('transform', 'translate(' + (pos - bbox.width / 2 - 5) + ', ' + 0 + ')'); + } else if (dir === 'BT') { + bkg.attr('x', pos - bbox.width / 2 - 10).attr('y', maxPos); + label.attr('transform', 'translate(' + (pos - bbox.width / 2 - 5) + ', ' + maxPos + ')'); + } else { + bkg.attr('transform', 'translate(' + -19 + ', ' + (pos - bbox.height / 2) + ')'); + } + }); +}; + +export const draw: DrawDefinition = function (txt, id, ver, diagObj) { + clear(); + const conf = getConfig(); + const gitGraphConfig = conf.gitGraph; + // try { + log.debug('in gitgraph renderer', txt + '\n', 'id:', id, ver); + const db = diagObj.db as GitGraphDB; + allCommitsDict = db.getCommits(); + const branches = db.getBranchesAsObjArray(); + dir = db.getDirection(); + const diagram = select(`[id="${id}"]`); + // Position branches + let pos = 0; + branches.forEach((branch, index) => { + const labelElement = drawText(branch.name); + const g = diagram.append('g'); + const branchLabel = g.insert('g').attr('class', 'branchLabel'); + const label = branchLabel.insert('g').attr('class', 'label branch-label'); + // @ts-ignore: TODO Fix ts errors + label.node().appendChild(labelElement); + const bbox = labelElement.getBBox(); + + branchPos.set(branch.name, { pos, index }); + pos += + 50 + + // @ts-ignore: TODO Fix ts errors + (gitGraphConfig.rotateCommitLabel ? 40 : 0) + + (dir === 'TB' || dir === 'BT' ? bbox.width / 2 : 0); + label.remove(); + branchLabel.remove(); + g.remove(); + }); + + drawCommits(diagram, allCommitsDict, false); + // @ts-ignore: TODO Fix ts errors + if (gitGraphConfig.showBranches) { + drawBranches(diagram, branches); + } + drawArrows(diagram, allCommitsDict); + drawCommits(diagram, allCommitsDict, true); + utils.insertTitle( + diagram, + 'gitTitleText', + // @ts-ignore: TODO Fix ts errors + gitGraphConfig.titleTopMargin, + db.getDiagramTitle() + ); + + // Setup the view box and size of the svg element + setupGraphViewbox( + undefined, + diagram, + // @ts-ignore: TODO Fix ts errors + gitGraphConfig.diagramPadding, + // @ts-ignore: TODO Fix ts errors + gitGraphConfig.useMaxWidth ?? conf.useMaxWidth + ); +}; + +export default { + draw, +}; diff --git a/packages/mermaid/src/diagrams/git/gitGraphTypes.ts b/packages/mermaid/src/diagrams/git/gitGraphTypes.ts index 29e2781a9..0e929666f 100644 --- a/packages/mermaid/src/diagrams/git/gitGraphTypes.ts +++ b/packages/mermaid/src/diagrams/git/gitGraphTypes.ts @@ -1,4 +1,13 @@ -export type CommitType = 'NORMAL' | 'REVERSE' | 'HIGHLIGHT' | 'MERGE' | 'CHERRY_PICK'; +import type { DiagramDB } from '../../diagram-api/types.js'; +import type { GitGraphDiagramConfig } from '../../config.type.js'; + +export interface CommitType { + NORMAL: number; + REVERSE: number; + HIGHLIGHT: number; + MERGE: number; + CHERRY_PICK: number; +} export interface Commit { id: string; @@ -16,6 +25,11 @@ export interface GitGraph { statements: Statement[]; } +export interface Position { + x: number; + y: number; +} + export type Statement = CommitAst | BranchAst | MergeAst | CheckoutAst | CherryPickingAst; export interface CommitAst { @@ -52,4 +66,47 @@ export interface CherryPickingAst { parent: string; } +export interface GitGraphDB extends DiagramDB { + // config + getConfig: () => GitGraphDiagramConfig | undefined; + + // common db + clear: () => void; + setDiagramTitle: (title: string) => void; + getDiagramTitle: () => string; + setAccTitle: (title: string) => void; + getAccTitle: () => string; + setAccDescription: (description: string) => void; + getAccDescription: () => string; + + // diagram db + commitType: CommitType; + setDirection: (direction: DiagramOrientation) => void; + getDirection: () => DiagramOrientation; + setOptions: (options: string) => void; + getOptions: () => string; + commit: (msg: string, id: string, type: number, tags?: string[] | undefined) => void; + branch: (name: string, order: number) => void; + merge: ( + otherBranch: string, + customId?: string, + overrideType?: number, + customTags?: string[] + ) => void; + cherryPick: ( + sourceId: string, + targetId: string, + tags: string[] | undefined, + parentCommitId: string + ) => void; + checkout: (branch: string) => void; + prettyPrint: () => void; + getBranchesAsObjArray: () => { name: string }[]; + getBranches: () => Map; + getCommits: () => Map; + getCommitsArray: () => Commit[]; + getCurrentBranch: () => string; + getHead: () => Commit | null; +} + export type DiagramOrientation = 'LR' | 'TB'; diff --git a/packages/mermaid/tsconfig.json b/packages/mermaid/tsconfig.json index f4451225e..66c6600f6 100644 --- a/packages/mermaid/tsconfig.json +++ b/packages/mermaid/tsconfig.json @@ -5,5 +5,10 @@ "outDir": "./dist", "types": ["vitest/importMeta", "vitest/globals"] }, - "include": ["./src/**/*.ts", "./package.json", "src/diagrams/gantt/ganttDb.js"] + "include": [ + "./src/**/*.ts", + "./package.json", + "src/diagrams/gantt/ganttDb.js", + "src/diagrams/git/gitGraphRenderer.js" + ] }