gitea/web_src/js/features/gitgraph.js
zeripath 2ab185d3ab
Rewrite GitGraph.js (#12137)
The current vendored gitgraph.js is no longer maintained and is
difficult to understand, fix and maintain.

This PR completely rewrites its logic - hopefully in a clearer fashion
and easier to maintain.

It also includes @silverwind's improvements of coloring the commit dots
and preventing the flash of incorrect content.

Further changes to contemplate in future will be abstracting out of the
flows to an object, storing the involved commit references on the flows
etc. However, this is probably a required step for this.

Replaces #12131
Fixes #11981 (part 3)

Signed-off-by: Andrew Thornton <art27@cantab.net>
2020-07-05 02:04:24 +01:00

568 lines
17 KiB
JavaScript

// 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');
}