// Although inspired by the https://github.com/bluef/gitgraph.js/blob/master/gitgraph.js // this has been completely rewritten with almost no remaining code // GitGraphCanvas is a canvas for drawing gitgraphs on to class GitGraphCanvas { constructor(canvas, widthUnits, heightUnits, config) { this.ctx = canvas.getContext('2d'); const width = widthUnits * config.unitSize; this.height = heightUnits * config.unitSize; const ratio = window.devicePixelRatio || 1; canvas.width = width * ratio; canvas.height = this.height * ratio; canvas.style.width = `${width}px`; canvas.style.height = `${this.height}px`; this.ctx.lineWidth = config.lineWidth; this.ctx.lineJoin = 'round'; this.ctx.lineCap = 'round'; this.ctx.scale(ratio, ratio); this.config = config; } drawLine(moveX, moveY, lineX, lineY, color) { this.ctx.strokeStyle = color; this.ctx.beginPath(); this.ctx.moveTo(moveX, moveY); this.ctx.lineTo(lineX, lineY); this.ctx.stroke(); } drawLineRight(x, y, color) { this.drawLine( x - 0.5 * this.config.unitSize, y + this.config.unitSize / 2, x + 0.5 * this.config.unitSize, y + this.config.unitSize / 2, color ); } drawLineUp(x, y, color) { this.drawLine( x, y + this.config.unitSize / 2, x, y - this.config.unitSize / 2, color ); } drawNode(x, y, color) { this.ctx.strokeStyle = color; this.drawLineUp(x, y, color); this.ctx.beginPath(); this.ctx.arc(x, y, this.config.nodeRadius, 0, Math.PI * 2, true); this.ctx.fillStyle = color; this.ctx.fill(); } drawLineIn(x, y, color) { this.drawLine( x + 0.5 * this.config.unitSize, y + this.config.unitSize / 2, x - 0.5 * this.config.unitSize, y - this.config.unitSize / 2, color ); } drawLineOut(x, y, color) { this.drawLine( x - 0.5 * this.config.unitSize, y + this.config.unitSize / 2, x + 0.5 * this.config.unitSize, y - this.config.unitSize / 2, color ); } drawSymbol(symbol, columnNumber, rowNumber, color) { const y = this.height - this.config.unitSize * (rowNumber + 0.5); const x = this.config.unitSize * 0.5 * (columnNumber + 1); switch (symbol) { case '-': if (columnNumber % 2 === 1) { this.drawLineRight(x, y, color); } break; case '_': this.drawLineRight(x, y, color); break; case '*': this.drawNode(x, y, color); break; case '|': this.drawLineUp(x, y, color); break; case '/': this.drawLineOut(x, y, color); break; case '\\': this.drawLineIn(x, y, color); break; case '.': case ' ': break; default: console.error('Unknown symbol', symbol, color); } } } class GitGraph { constructor(canvas, rawRows, config) { this.rows = []; let maxWidth = 0; for (let i = 0; i < rawRows.length; i++) { const rowStr = rawRows[i]; maxWidth = Math.max(rowStr.replace(/([_\s.-])/g, '').length, maxWidth); const rowArray = rowStr.split(''); this.rows.unshift(rowArray); } this.currentFlows = []; this.previousFlows = []; this.gitGraphCanvas = new GitGraphCanvas( canvas, maxWidth, this.rows.length, config ); } generateNewFlow(column) { let newId; do { newId = generateRandomColorString(); } while (this.hasFlow(newId, column)); return {id: newId, color: `#${newId}`}; } hasFlow(id, column) { // We want to find the flow with the current ID // Possible flows are those in the currentFlows // Or flows in previousFlows[column-2:...] for ( let idx = column - 2 < 0 ? 0 : column - 2; idx < this.previousFlows.length; idx++ ) { if (this.previousFlows[idx] && this.previousFlows[idx].id === id) { return true; } } for (let idx = 0; idx < this.currentFlows.length; idx++) { if (this.currentFlows[idx] && this.currentFlows[idx].id === id) { return true; } } return false; } takePreviousFlow(column) { if (column < this.previousFlows.length && this.previousFlows[column]) { const flow = this.previousFlows[column]; this.previousFlows[column] = null; return flow; } return this.generateNewFlow(column); } draw() { if (this.rows.length === 0) { return; } this.currentFlows = new Array(this.rows[0].length); // Generate flows for the first row - I do not believe that this can contain '_', '-', '.' for (let column = 0; column < this.rows[0].length; column++) { if (this.rows[0][column] === ' ') { continue; } this.currentFlows[column] = this.generateNewFlow(column); } // Draw the first row for (let column = 0; column < this.rows[0].length; column++) { const symbol = this.rows[0][column]; const color = this.currentFlows[column] ? this.currentFlows[column].color : ''; this.gitGraphCanvas.drawSymbol(symbol, column, 0, color); } for (let row = 1; row < this.rows.length; row++) { // Done previous row - step up the row const currentRow = this.rows[row]; const previousRow = this.rows[row - 1]; this.previousFlows = this.currentFlows; this.currentFlows = new Array(currentRow.length); // Set flows for this row for (let column = 0; column < currentRow.length; column++) { column = this.setFlowFor(column, currentRow, previousRow); } // Draw this row for (let column = 0; column < currentRow.length; column++) { const symbol = currentRow[column]; const color = this.currentFlows[column] ? this.currentFlows[column].color : ''; this.gitGraphCanvas.drawSymbol(symbol, column, row, color); } } } setFlowFor(column, currentRow, previousRow) { const symbol = currentRow[column]; switch (symbol) { case '|': case '*': return this.setUpFlow(column, currentRow, previousRow); case '/': return this.setOutFlow(column, currentRow, previousRow); case '\\': return this.setInFlow(column, currentRow, previousRow); case '_': return this.setRightFlow(column, currentRow, previousRow); case '-': return this.setLeftFlow(column, currentRow, previousRow); case ' ': // In space no one can hear you flow ... (?) return column; default: // Unexpected so let's generate a new flow and wait for bug-reports this.currentFlows[column] = this.generateNewFlow(column); return column; } } // setUpFlow handles '|' or '*' - returns the last column that was set // generally we prefer to take the left most flow from the previous row setUpFlow(column, currentRow, previousRow) { // If ' |/' or ' |_' // '/|' '/|' -> Take the '|' flow directly beneath us if ( column + 1 < currentRow.length && (currentRow[column + 1] === '/' || currentRow[column + 1] === '_') && column < previousRow.length && (previousRow[column] === '|' || previousRow[column] === '*') && previousRow[column - 1] === '/' ) { this.currentFlows[column] = this.takePreviousFlow(column); return column; } // If ' |/' or ' |_' // '/ ' '/ ' -> Take the '/' flow from the preceding column if ( column + 1 < currentRow.length && (currentRow[column + 1] === '/' || currentRow[column + 1] === '_') && column - 1 < previousRow.length && previousRow[column - 1] === '/' ) { this.currentFlows[column] = this.takePreviousFlow(column - 1); return column; } // If ' |' // '/' -> Take the '/' flow - (we always prefer the left-most flow) if ( column > 0 && column - 1 < previousRow.length && previousRow[column - 1] === '/' ) { this.currentFlows[column] = this.takePreviousFlow(column - 1); return column; } // If '|' OR '|' take the '|' flow // '|' '*' if ( column < previousRow.length && (previousRow[column] === '|' || previousRow[column] === '*') ) { this.currentFlows[column] = this.takePreviousFlow(column); return column; } // If '| ' keep the '\' flow // ' \' if (column + 1 < previousRow.length && previousRow[column + 1] === '\\') { this.currentFlows[column] = this.takePreviousFlow(column + 1); return column; } // Otherwise just create a new flow - probably this is an error... this.currentFlows[column] = this.generateNewFlow(column); return column; } // setOutFlow handles '/' - returns the last column that was set // generally we prefer to take the left most flow from the previous row setOutFlow(column, currentRow, previousRow) { // If '_/' -> keep the '_' flow if (column > 0 && currentRow[column - 1] === '_') { this.currentFlows[column] = this.currentFlows[column - 1]; return column; } // If '_|/' -> keep the '_' flow if ( column > 1 && (currentRow[column - 1] === '|' || currentRow[column - 1] === '*') && currentRow[column - 2] === '_' ) { this.currentFlows[column] = this.currentFlows[column - 2]; return column; } // If '|/' // '/' -> take the '/' flow (if it is still available) if ( column > 1 && currentRow[column - 1] === '|' && column - 2 < previousRow.length && previousRow[column - 2] === '/' ) { this.currentFlows[column] = this.takePreviousFlow(column - 2); return column; } // If ' /' // '/' -> take the '/' flow, but transform the symbol to '|' due to our spacing // This should only happen if there are 3 '/' - in a row so we don't need to be cleverer here if ( column > 0 && currentRow[column - 1] === ' ' && column - 1 < previousRow.length && previousRow[column - 1] === '/' ) { this.currentFlows[column] = this.takePreviousFlow(column - 1); currentRow[column] = '|'; return column; } // If ' /' // '|' -> take the '|' flow if ( column > 0 && currentRow[column - 1] === ' ' && column - 1 < previousRow.length && (previousRow[column - 1] === '|' || previousRow[column - 1] === '*') ) { this.currentFlows[column] = this.takePreviousFlow(column - 1); return column; } // If '/' <- Not sure this ever happens... but take the '\' flow // '\' if (column < previousRow.length && previousRow[column] === '\\') { this.currentFlows[column] = this.takePreviousFlow(column); return column; } // Otherwise just generate a new flow and wait for bug-reports... this.currentFlows[column] = this.generateNewFlow(column); return column; } // setInFlow handles '\' - returns the last column that was set // generally we prefer to take the left most flow from the previous row setInFlow(column, currentRow, previousRow) { // If '\?' // '/?' -> take the '/' flow if (column < previousRow.length && previousRow[column] === '/') { this.currentFlows[column] = this.takePreviousFlow(column); return column; } // If '\?' // ' \' -> take the '\' flow and reassign to '|' // This should only happen if there are 3 '\' - in a row so we don't need to be cleverer here if (column + 1 < previousRow.length && previousRow[column + 1] === '\\') { this.currentFlows[column] = this.takePreviousFlow(column + 1); currentRow[column] = '|'; return column; } // If '\?' // ' |' -> take the '|' flow if ( column + 1 < previousRow.length && (previousRow[column + 1] === '|' || previousRow[column + 1] === '*') ) { this.currentFlows[column] = this.takePreviousFlow(column + 1); return column; } // Otherwise just generate a new flow and wait for bug-reports if we're wrong... this.currentFlows[column] = this.generateNewFlow(column); return column; } // setRightFlow handles '_' - returns the last column that was set // generally we prefer to take the left most flow from the previous row setRightFlow(column, currentRow, previousRow) { // if '__' keep the '_' flow if (column > 0 && currentRow[column - 1] === '_') { this.currentFlows[column] = this.currentFlows[column - 1]; return column; } // if '_|_' -> keep the '_' flow if ( column > 1 && currentRow[column - 1] === '|' && currentRow[column - 2] === '_' ) { this.currentFlows[column] = this.currentFlows[column - 2]; return column; } // if ' _' -> take the '/' flow // '/ ' if ( column > 0 && column - 1 < previousRow.length && previousRow[column - 1] === '/' ) { this.currentFlows[column] = this.takePreviousFlow(column - 1); return column; } // if ' |_' // '/? ' -> take the '/' flow (this may cause generation...) // we can do this because we know that git graph // doesn't create compact graphs like: ' |_' // '//' if ( column > 1 && column - 2 < previousRow.length && previousRow[column - 2] === '/' ) { this.currentFlows[column] = this.takePreviousFlow(column - 2); return column; } // There really shouldn't be another way of doing this - generate and wait for bug-reports... this.currentFlows[column] = this.generateNewFlow(column); return column; } // setLeftFlow handles '----.' - returns the last column that was set // generally we prefer to take the left most flow from the previous row that terminates this left recursion setLeftFlow(column, currentRow, previousRow) { // This is: '----------.' or the like // ' \ \ /|\' // Find the end of the '-' or nearest '/|\' in the previousRow : let originalColumn = column; let flow; for (; column < currentRow.length && currentRow[column] === '-'; column++) { if (column > 0 && column - 1 < previousRow.length && previousRow[column - 1] === '/') { flow = this.takePreviousFlow(column - 1); break; } else if (column < previousRow.length && previousRow[column] === '|') { flow = this.takePreviousFlow(column); break; } else if ( column + 1 < previousRow.length && previousRow[column + 1] === '\\' ) { flow = this.takePreviousFlow(column + 1); break; } } // if we have a flow then we found a '/|\' in the previousRow if (flow) { for (; originalColumn < column + 1; originalColumn++) { this.currentFlows[originalColumn] = flow; } return column; } // If the symbol in the column is not a '.' then there's likely an error if (currentRow[column] !== '.') { // It really should end in a '.' but this one doesn't... // 1. Step back - we don't want to eat this column column--; // 2. Generate a new flow and await bug-reports... this.currentFlows[column] = this.generateNewFlow(column); // 3. Assign all of the '-' to the same flow. for (; originalColumn < column; originalColumn++) { this.currentFlows[originalColumn] = this.currentFlows[column]; } return column; } // We have a terminal '.' eg. the current row looks like '----.' // the previous row should look like one of '/|\' eg. ' \' if (column > 0 && column - 1 < previousRow.length && previousRow[column - 1] === '/') { flow = this.takePreviousFlow(column - 1); } else if (column < previousRow.length && previousRow[column] === '|') { flow = this.takePreviousFlow(column); } else if ( column + 1 < previousRow.length && previousRow[column + 1] === '\\' ) { flow = this.takePreviousFlow(column + 1); } else { // Again unexpected so let's generate and wait the bug-report flow = this.generateNewFlow(column); } // Assign all of the rest of the ----. to this flow. for (; originalColumn < column + 1; originalColumn++) { this.currentFlows[originalColumn] = flow; } return column; } } function generateRandomColorString() { const chars = '0123456789ABCDEF'; const stringLength = 6; let randomString = '', rnum, i; for (i = 0; i < stringLength; i++) { rnum = Math.floor(Math.random() * chars.length); randomString += chars.substring(rnum, rnum + 1); } return randomString; } export default async function initGitGraph() { const graphCanvas = document.getElementById('graph-canvas'); if (!graphCanvas || !graphCanvas.getContext) return; // Grab the raw graphList const graphList = []; $('#graph-raw-list li span.node-relation').each(function () { graphList.push($(this).text()); }); // Define some drawing parameters const config = { unitSize: 20, lineWidth: 3, nodeRadius: 4 }; const gitGraph = new GitGraph(graphCanvas, graphList, config); gitGraph.draw(); graphCanvas.closest('#git-graph-container').classList.add('in'); }