parent
4dbf3aa648
commit
577b0b5c24
@ -0,0 +1,21 @@ |
|||||||
|
The MIT License (MIT) |
||||||
|
|
||||||
|
Copyright (c) 2013 Nicolas CARLO and Fabien BERNARD |
||||||
|
Copyright (c) 2022-2024 Niko Bonnieure, Par le Peuple, NextGraph.org developers |
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of |
||||||
|
this software and associated documentation files (the "Software"), to deal in |
||||||
|
the Software without restriction, including without limitation the rights to |
||||||
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of |
||||||
|
the Software, and to permit persons to whom the Software is furnished to do so, |
||||||
|
subject to the following conditions: |
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all |
||||||
|
copies or substantial portions of the Software. |
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS |
||||||
|
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR |
||||||
|
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER |
||||||
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
||||||
|
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
@ -0,0 +1,74 @@ |
|||||||
|
import type { Commit, CommitRenderOptions } from "./commit"; |
||||||
|
import type { GitgraphCore } from "./gitgraph"; |
||||||
|
import type { TemplateOptions, BranchStyle } from "./template"; |
||||||
|
|
||||||
|
export { |
||||||
|
type BranchCommitDefaultOptions, |
||||||
|
type BranchRenderOptions, |
||||||
|
type BranchOptions, |
||||||
|
Branch, |
||||||
|
}; |
||||||
|
|
||||||
|
interface BranchCommitDefaultOptions<TNode> extends CommitRenderOptions<TNode> { |
||||||
|
author?: string; |
||||||
|
subject?: string; |
||||||
|
style?: TemplateOptions["commit"]; |
||||||
|
} |
||||||
|
|
||||||
|
interface BranchRenderOptions<TNode> { |
||||||
|
renderLabel?: (branch: Branch<TNode>) => TNode; |
||||||
|
} |
||||||
|
|
||||||
|
interface BranchOptions<TNode = SVGElement> extends BranchRenderOptions<TNode> { |
||||||
|
/** |
||||||
|
* Gitgraph constructor |
||||||
|
*/ |
||||||
|
gitgraph: GitgraphCore<TNode>; |
||||||
|
/** |
||||||
|
* Branch name |
||||||
|
*/ |
||||||
|
name: string; |
||||||
|
/** |
||||||
|
* Branch style |
||||||
|
*/ |
||||||
|
style: BranchStyle; |
||||||
|
/** |
||||||
|
* Parent commit |
||||||
|
*/ |
||||||
|
parentCommitHash?: Commit["hash"]; |
||||||
|
/** |
||||||
|
* Default options for commits |
||||||
|
*/ |
||||||
|
commitDefaultOptions?: BranchCommitDefaultOptions<TNode>; |
||||||
|
/** |
||||||
|
* On graph update. |
||||||
|
*/ |
||||||
|
onGraphUpdate: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
const DELETED_BRANCH_NAME = ""; |
||||||
|
|
||||||
|
class Branch<TNode = SVGElement> { |
||||||
|
public name: BranchOptions["name"]; |
||||||
|
public style: BranchStyle; |
||||||
|
public index: number = 0; |
||||||
|
public computedColor?: BranchStyle["color"]; |
||||||
|
public parentCommitHash: BranchOptions["parentCommitHash"]; |
||||||
|
public commitDefaultOptions: BranchCommitDefaultOptions<TNode>; |
||||||
|
public renderLabel: BranchOptions<TNode>["renderLabel"]; |
||||||
|
|
||||||
|
private gitgraph: GitgraphCore<TNode>; |
||||||
|
private onGraphUpdate: () => void; |
||||||
|
|
||||||
|
constructor(options: BranchOptions<TNode>) { |
||||||
|
this.gitgraph = options.gitgraph; |
||||||
|
this.name = options.name; |
||||||
|
this.style = options.style; |
||||||
|
this.parentCommitHash = options.parentCommitHash; |
||||||
|
this.commitDefaultOptions = options.commitDefaultOptions || { style: {} }; |
||||||
|
this.onGraphUpdate = options.onGraphUpdate; |
||||||
|
this.renderLabel = options.renderLabel; |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
@ -0,0 +1,342 @@ |
|||||||
|
import type { Commit } from "./commit"; |
||||||
|
import type { Branch } from "./branch"; |
||||||
|
import type { CommitStyleBase } from "./template"; |
||||||
|
import { pick } from "./utils"; |
||||||
|
import type { GraphRows } from "./graph-rows"; |
||||||
|
|
||||||
|
export { type BranchesPaths, type Coordinate, BranchesPathsCalculator, toSvgPath }; |
||||||
|
|
||||||
|
type BranchesPaths<TNode> = Map<Branch<TNode>, Coordinate[][]>; |
||||||
|
|
||||||
|
interface Coordinate { |
||||||
|
x: number; |
||||||
|
y: number; |
||||||
|
} |
||||||
|
|
||||||
|
type InternalBranchesPaths<TNode> = Map<Branch<TNode>, InternalPaths>; |
||||||
|
|
||||||
|
class InternalPaths { |
||||||
|
forks: Coordinate[][]; |
||||||
|
branch: Coordinate[]; |
||||||
|
merges: Coordinate[][]; |
||||||
|
|
||||||
|
constructor( |
||||||
|
) { |
||||||
|
this.forks = []; |
||||||
|
this.branch = []; |
||||||
|
this.merges = []; |
||||||
|
} |
||||||
|
|
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* Calculate branches paths of the graph. |
||||||
|
* |
||||||
|
* It follows the Command pattern: |
||||||
|
* => a class with a single `execute()` public method. |
||||||
|
* |
||||||
|
* Main benefit is we can split computation in smaller steps without |
||||||
|
* passing around parameters (we can rely on private data). |
||||||
|
*/ |
||||||
|
class BranchesPathsCalculator<TNode> { |
||||||
|
private commits: Array<Commit<TNode>>; |
||||||
|
private rows:GraphRows<TNode>; |
||||||
|
private branches: Map<Branch["name"], Branch<TNode>>; |
||||||
|
private isGraphReverse: boolean; |
||||||
|
private commitSpacing: CommitStyleBase["spacing"]; |
||||||
|
private branchesPaths: InternalBranchesPaths<TNode> = new Map< |
||||||
|
Branch<TNode>, |
||||||
|
InternalPaths |
||||||
|
>(); |
||||||
|
|
||||||
|
constructor( |
||||||
|
commits: Array<Commit<TNode>>, |
||||||
|
rows: GraphRows<TNode>, |
||||||
|
branches: Map<Branch["name"], Branch<TNode>>, |
||||||
|
isGraphReverse: boolean, |
||||||
|
commitSpacing: CommitStyleBase["spacing"], |
||||||
|
) { |
||||||
|
this.commits = commits; |
||||||
|
this.rows = rows; |
||||||
|
this.branches = branches; |
||||||
|
this.commitSpacing = commitSpacing; |
||||||
|
this.isGraphReverse = isGraphReverse; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Compute branches paths for graph. |
||||||
|
*/ |
||||||
|
public execute(): BranchesPaths<TNode> { |
||||||
|
return this.fromCommits(); |
||||||
|
// this.withMergeCommits();
|
||||||
|
// return this.smoothBranchesPaths();
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Initialize branches paths from calculator's commits. |
||||||
|
*/ |
||||||
|
private fromCommits() : BranchesPaths<TNode> { |
||||||
|
const direction = this.isGraphReverse ? -1 : 1 ; |
||||||
|
this.commits.forEach((commit) => { |
||||||
|
let branch = this.branches.get(commit.branch); |
||||||
|
|
||||||
|
let existingBranchPath = this.branchesPaths.get(branch); |
||||||
|
if (!existingBranchPath) { |
||||||
|
let internal = new InternalPaths(); |
||||||
|
commit.parents.forEach((parent) => { |
||||||
|
|
||||||
|
let rowOfParent = this.rows.getRowOf(parent); |
||||||
|
let parentCommit = this.commits[rowOfParent]; |
||||||
|
if (parentCommit.col <= commit.col) { |
||||||
|
// this is a fork
|
||||||
|
let path: Coordinate[] = []; |
||||||
|
path.push({ x: parentCommit.x, y: parentCommit.y }); |
||||||
|
// add the smoothing points towards the first commit of the branch
|
||||||
|
let distance = commit.row - rowOfParent; |
||||||
|
for (let d=0; d<distance; d++) { |
||||||
|
path.push({ x: commit.x, y: parentCommit.y + (1 + d)*this.commitSpacing * direction }); |
||||||
|
} |
||||||
|
internal.forks.push(path); |
||||||
|
} |
||||||
|
}); |
||||||
|
internal.branch.push({ x: commit.x, y: commit.y }); |
||||||
|
this.branchesPaths.set(branch, internal); |
||||||
|
} else { |
||||||
|
if (commit.parents.length == 1) { |
||||||
|
existingBranchPath.branch.push({ x: commit.x, y: commit.y }); |
||||||
|
} else { |
||||||
|
commit.parents.forEach((p) => { |
||||||
|
let rowOfParent = this.rows.getRowOf(p); |
||||||
|
let parentCommit = this.commits[rowOfParent]; |
||||||
|
if (parentCommit.col == commit.col) { |
||||||
|
existingBranchPath.branch.push({ x: commit.x, y: commit.y }); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
// doing the merges
|
||||||
|
if (commit.parents.length > 1) { |
||||||
|
commit.parents.forEach((parent) => { |
||||||
|
let rowOfParent = this.rows.getRowOf(parent); |
||||||
|
let parentCommit = this.commits[rowOfParent]; |
||||||
|
if (parentCommit.col > commit.col) { |
||||||
|
// this is a merge
|
||||||
|
let path: Coordinate[] = []; |
||||||
|
path.push({ x: parentCommit.x, y: parentCommit.y }); |
||||||
|
// add the smoothing points towards the merge commit
|
||||||
|
let distance = commit.row - rowOfParent - 1; |
||||||
|
for (let d=0; d<distance; d++) { |
||||||
|
path.push({ x: parentCommit.x, y: parentCommit.y + (1 + d)*this.commitSpacing * direction}); |
||||||
|
} |
||||||
|
path.push({ x: commit.x, y: commit.y }); |
||||||
|
// adding this path to the internal of the merged branch
|
||||||
|
let mergedBranchPath = this.branchesPaths.get(this.branches.get(parentCommit.branch)); |
||||||
|
mergedBranchPath.merges.push(path); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
// const firstParentCommit = this.commits.find(
|
||||||
|
// ({ hash }) => hash === commit.parents[0],
|
||||||
|
// );
|
||||||
|
// if (existingBranchPath) {
|
||||||
|
// path.push(...existingBranchPath);
|
||||||
|
// } else if (firstParentCommit) {
|
||||||
|
// // Make branch path starts from parent branch (parent commit).
|
||||||
|
// path.push({ x: firstParentCommit.x, y: firstParentCommit.y });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// this.branchesPaths.set(branch, path);
|
||||||
|
}); |
||||||
|
|
||||||
|
const branchesPaths = new Map<Branch<TNode>, Coordinate[][]>(); |
||||||
|
|
||||||
|
this.branchesPaths.forEach((internal, branch) => { |
||||||
|
branchesPaths.set(branch, [ |
||||||
|
...internal.forks, |
||||||
|
internal.branch, |
||||||
|
...internal.merges, |
||||||
|
]); |
||||||
|
}); |
||||||
|
|
||||||
|
return branchesPaths; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Insert merge commits points into `branchesPaths`. |
||||||
|
* |
||||||
|
* @example |
||||||
|
* // Before
|
||||||
|
* [ |
||||||
|
* { x: 0, y: 640 }, |
||||||
|
* { x: 50, y: 560 } |
||||||
|
* ] |
||||||
|
* |
||||||
|
* // After
|
||||||
|
* [ |
||||||
|
* { x: 0, y: 640 }, |
||||||
|
* { x: 50, y: 560 }, |
||||||
|
* { x: 50, y: 560, mergeCommit: true } |
||||||
|
* ] |
||||||
|
*/ |
||||||
|
// private withMergeCommits() {
|
||||||
|
// const mergeCommits = this.commits.filter(
|
||||||
|
// ({ parents }) => parents.length > 1,
|
||||||
|
// );
|
||||||
|
|
||||||
|
// mergeCommits.forEach((mergeCommit) => {
|
||||||
|
|
||||||
|
// let branch = this.branches.get(mergeCommit.branch);
|
||||||
|
|
||||||
|
// const lastPoints = [...(this.branchesPaths.get(branch) || [])];
|
||||||
|
// this.branchesPaths.set(branch, [
|
||||||
|
// ...lastPoints,
|
||||||
|
// { x: mergeCommit.x, y: mergeCommit.y, mergeCommit: true },
|
||||||
|
// ]);
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
/** |
||||||
|
* Smooth all paths by putting points on each row. |
||||||
|
*/ |
||||||
|
// private smoothBranchesPaths(): BranchesPaths<TNode> {
|
||||||
|
// const branchesPaths = new Map<Branch<TNode>, Coordinate[][]>();
|
||||||
|
|
||||||
|
// this.branchesPaths.forEach((points, branch) => {
|
||||||
|
// if (points.length <= 1) {
|
||||||
|
// branchesPaths.set(branch, [points]);
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Cut path on each merge commits
|
||||||
|
// // Coordinate[] -> Coordinate[][]
|
||||||
|
|
||||||
|
// points = points.sort((a, b) => (a.y > b.y ? -1 : 1));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// points = points.reverse();
|
||||||
|
|
||||||
|
|
||||||
|
// const paths = points.reduce<Coordinate[][]>(
|
||||||
|
// (mem, point, i) => {
|
||||||
|
// if (point.mergeCommit) {
|
||||||
|
// mem[mem.length - 1].push(pick(point, ["x", "y"]));
|
||||||
|
// let j = i - 1;
|
||||||
|
// let previousPoint = points[j];
|
||||||
|
|
||||||
|
// // Find the last point which is not a merge
|
||||||
|
// while (j >= 0 && previousPoint.mergeCommit) {
|
||||||
|
// j--;
|
||||||
|
// previousPoint = points[j];
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Start a new array with this point
|
||||||
|
// if (j >= 0) {
|
||||||
|
// mem.push([previousPoint]);
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// mem[mem.length - 1].push(point);
|
||||||
|
// }
|
||||||
|
// return mem;
|
||||||
|
// },
|
||||||
|
// [[]],
|
||||||
|
// );
|
||||||
|
|
||||||
|
|
||||||
|
// paths.forEach((path) => path.reverse());
|
||||||
|
|
||||||
|
|
||||||
|
// // Add intermediate points on each sub paths
|
||||||
|
// if (true) {
|
||||||
|
// paths.forEach((subPath) => {
|
||||||
|
// if (subPath.length <= 1) return;
|
||||||
|
// const firstPoint = subPath[0];
|
||||||
|
// const lastPoint = subPath[subPath.length - 1];
|
||||||
|
// const column = subPath[1].x;
|
||||||
|
// const branchSize =
|
||||||
|
// Math.round(
|
||||||
|
// Math.abs(firstPoint.y - lastPoint.y) / this.commitSpacing,
|
||||||
|
// ) - 1;
|
||||||
|
// const branchPoints =
|
||||||
|
// branchSize > 0
|
||||||
|
// ? new Array(branchSize).fill(0).map((_, i) => ({
|
||||||
|
// x: column,
|
||||||
|
// y: subPath[0].y - this.commitSpacing * (i + 1),
|
||||||
|
// }))
|
||||||
|
// : [];
|
||||||
|
// const lastSubPaths = branchesPaths.get(branch) || [];
|
||||||
|
// branchesPaths.set(branch, [
|
||||||
|
// ...lastSubPaths,
|
||||||
|
// [firstPoint, ...branchPoints, lastPoint],
|
||||||
|
// ]);
|
||||||
|
// });
|
||||||
|
// } else {
|
||||||
|
// // paths.forEach((subPath) => {
|
||||||
|
// // if (subPath.length <= 1) return;
|
||||||
|
// // const firstPoint = subPath[0];
|
||||||
|
// // const lastPoint = subPath[subPath.length - 1];
|
||||||
|
// // const column = subPath[1].y;
|
||||||
|
// // const branchSize =
|
||||||
|
// // Math.round(
|
||||||
|
// // Math.abs(firstPoint.x - lastPoint.x) / this.commitSpacing,
|
||||||
|
// // ) - 1;
|
||||||
|
// // const branchPoints =
|
||||||
|
// // branchSize > 0
|
||||||
|
// // ? new Array(branchSize).fill(0).map((_, i) => ({
|
||||||
|
// // y: column,
|
||||||
|
// // x: subPath[0].x + this.commitSpacing * (i + 1),
|
||||||
|
// // }))
|
||||||
|
// // : [];
|
||||||
|
// // const lastSubPaths = branchesPaths.get(branch) || [];
|
||||||
|
// // branchesPaths.set(branch, [
|
||||||
|
// // ...lastSubPaths,
|
||||||
|
// // [firstPoint, ...branchPoints, lastPoint],
|
||||||
|
// // ]);
|
||||||
|
// // });
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
// return branchesPaths;
|
||||||
|
// }
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Return a string ready to use in `svg.path.d` from coordinates |
||||||
|
* |
||||||
|
* @param coordinates Collection of coordinates |
||||||
|
*/ |
||||||
|
function toSvgPath( |
||||||
|
coordinates: Coordinate[][], |
||||||
|
isBezier: boolean, |
||||||
|
//isVertical: boolean,
|
||||||
|
): string { |
||||||
|
return coordinates |
||||||
|
.map( |
||||||
|
(path) => |
||||||
|
"M" + |
||||||
|
path |
||||||
|
.map(({ x, y }, i, points) => { |
||||||
|
if ( |
||||||
|
isBezier && |
||||||
|
points.length > 1 && |
||||||
|
(i === 1 || i === points.length - 1) |
||||||
|
) { |
||||||
|
const previous = points[i - 1]; |
||||||
|
//if (isVertical) {
|
||||||
|
const middleY = (previous.y + y) / 2; |
||||||
|
return `C ${previous.x} ${middleY} ${x} ${middleY} ${x} ${y}`; |
||||||
|
// } else {
|
||||||
|
// const middleX = (previous.x + x) / 2;
|
||||||
|
// return `C ${middleX} ${previous.y} ${middleX} ${y} ${x} ${y}`;
|
||||||
|
// }
|
||||||
|
} |
||||||
|
return `L ${x} ${y}`; |
||||||
|
}) |
||||||
|
.join(" ") |
||||||
|
.slice(1), |
||||||
|
) |
||||||
|
.join(" "); |
||||||
|
} |
@ -0,0 +1,275 @@ |
|||||||
|
import type { CommitStyle } from "./template"; |
||||||
|
import type { Branch } from "./branch"; |
||||||
|
|
||||||
|
export { type CommitRenderOptions, type CommitOptions, Commit }; |
||||||
|
|
||||||
|
interface CommitRenderOptions<TNode> { |
||||||
|
renderDot?: (commit: Commit<TNode>) => TNode; |
||||||
|
renderMessage?: (commit: Commit<TNode>) => TNode; |
||||||
|
renderTooltip?: (commit: Commit<TNode>) => TNode; |
||||||
|
} |
||||||
|
|
||||||
|
interface CommitOptions<TNode> extends CommitRenderOptions<TNode> { |
||||||
|
author: string; |
||||||
|
subject: string; |
||||||
|
style: CommitStyle; |
||||||
|
x: number; |
||||||
|
y: number; |
||||||
|
body?: string; |
||||||
|
hash?: string; |
||||||
|
parents?: string[]; |
||||||
|
dotText?: string; |
||||||
|
branch?: Branch["name"]; |
||||||
|
onClick?: (commit: Commit<TNode>) => void; |
||||||
|
onMessageClick?: (commit: Commit<TNode>) => void; |
||||||
|
onMouseOver?: (commit: Commit<TNode>) => void; |
||||||
|
onMouseOut?: (commit: Commit<TNode>) => void; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Generate a random hash. |
||||||
|
* |
||||||
|
* @return hex string with 40 chars |
||||||
|
*/ |
||||||
|
const getRandomHash = () => |
||||||
|
( |
||||||
|
Math.random().toString(16).substring(3) + |
||||||
|
Math.random().toString(16).substring(3) + |
||||||
|
Math.random().toString(16).substring(3) + |
||||||
|
Math.random().toString(16).substring(3) |
||||||
|
).substring(0, 40); |
||||||
|
|
||||||
|
class Commit<TNode = SVGElement> { |
||||||
|
|
||||||
|
public branch?: Branch["name"]; |
||||||
|
/** |
||||||
|
* Commit x position |
||||||
|
*/ |
||||||
|
public x = 0; |
||||||
|
/** |
||||||
|
* Commit y position |
||||||
|
*/ |
||||||
|
public y = 0; |
||||||
|
/** |
||||||
|
* Commit hash |
||||||
|
*/ |
||||||
|
public row = 0; |
||||||
|
|
||||||
|
public col = 0; |
||||||
|
|
||||||
|
public hash: string; |
||||||
|
/** |
||||||
|
* Abbreviated commit hash |
||||||
|
*/ |
||||||
|
public hashAbbrev: string; |
||||||
|
/** |
||||||
|
* Parent hashes |
||||||
|
*/ |
||||||
|
public parents: Array<Commit<TNode>["hash"]>; |
||||||
|
/** |
||||||
|
* Abbreviated parent hashed |
||||||
|
*/ |
||||||
|
public parentsAbbrev: Array<Commit<TNode>["hashAbbrev"]>; |
||||||
|
/** |
||||||
|
* Author |
||||||
|
*/ |
||||||
|
public author: { |
||||||
|
/** |
||||||
|
* Author name |
||||||
|
*/ |
||||||
|
name: string; |
||||||
|
/** |
||||||
|
* Author email |
||||||
|
*/ |
||||||
|
email?: string; |
||||||
|
/** |
||||||
|
* Author date |
||||||
|
*/ |
||||||
|
timestamp?: number; |
||||||
|
}; |
||||||
|
/** |
||||||
|
* Committer |
||||||
|
*/ |
||||||
|
public committer: { |
||||||
|
/** |
||||||
|
* Commiter name |
||||||
|
*/ |
||||||
|
name: string; |
||||||
|
/** |
||||||
|
* Commiter email |
||||||
|
*/ |
||||||
|
email?: string; |
||||||
|
/** |
||||||
|
* Commiter date |
||||||
|
*/ |
||||||
|
timestamp?: number; |
||||||
|
}; |
||||||
|
/** |
||||||
|
* Subject |
||||||
|
*/ |
||||||
|
public subject: string; |
||||||
|
/** |
||||||
|
* Body |
||||||
|
*/ |
||||||
|
public body: string; |
||||||
|
/** |
||||||
|
* Message |
||||||
|
*/ |
||||||
|
public get message() { |
||||||
|
let message = ""; |
||||||
|
|
||||||
|
if (this.style.message.displayHash) { |
||||||
|
message += `${this.hashAbbrev} `; |
||||||
|
} |
||||||
|
|
||||||
|
message += this.subject; |
||||||
|
|
||||||
|
if (this.style.message.displayAuthor) { |
||||||
|
message += ` - ${this.author.name} <${this.author.email}>`; |
||||||
|
} |
||||||
|
|
||||||
|
return message; |
||||||
|
} |
||||||
|
/** |
||||||
|
* Style |
||||||
|
*/ |
||||||
|
public style: CommitStyle; |
||||||
|
/** |
||||||
|
* Text inside commit dot |
||||||
|
*/ |
||||||
|
public dotText?: string; |
||||||
|
/** |
||||||
|
* List of branches attached |
||||||
|
*/ |
||||||
|
|
||||||
|
/** |
||||||
|
* Callback to execute on click. |
||||||
|
*/ |
||||||
|
public onClick: () => void; |
||||||
|
/** |
||||||
|
* Callback to execute on click on the commit message. |
||||||
|
*/ |
||||||
|
public onMessageClick: () => void; |
||||||
|
/** |
||||||
|
* Callback to execute on mouse over. |
||||||
|
*/ |
||||||
|
public onMouseOver: () => void; |
||||||
|
/** |
||||||
|
* Callback to execute on mouse out. |
||||||
|
*/ |
||||||
|
public onMouseOut: () => void; |
||||||
|
/** |
||||||
|
* Custom dot render |
||||||
|
*/ |
||||||
|
public renderDot?: (commit: Commit<TNode>) => TNode; |
||||||
|
/** |
||||||
|
* Custom message render |
||||||
|
*/ |
||||||
|
public renderMessage?: (commit: Commit<TNode>) => TNode; |
||||||
|
/** |
||||||
|
* Custom tooltip render |
||||||
|
*/ |
||||||
|
public renderTooltip?: (commit: Commit<TNode>) => TNode; |
||||||
|
|
||||||
|
constructor(options: CommitOptions<TNode>) { |
||||||
|
// Set author & committer
|
||||||
|
let name = options.author; |
||||||
|
|
||||||
|
this.col = options.x; |
||||||
|
this.row = options.y; |
||||||
|
|
||||||
|
this.author = { name }; |
||||||
|
this.committer = { name }; |
||||||
|
this.branch = options.branch; |
||||||
|
|
||||||
|
// Set commit message
|
||||||
|
this.subject = options.subject; |
||||||
|
this.body = options.body || ""; |
||||||
|
|
||||||
|
// Set commit hash
|
||||||
|
this.hash = options.hash || getRandomHash(); |
||||||
|
this.hashAbbrev = this.hash.substring(0, 7); |
||||||
|
|
||||||
|
// Set parent hash
|
||||||
|
this.parents = options.parents ? options.parents : []; |
||||||
|
this.parentsAbbrev = this.parents.map((commit) => commit.substring(0, 7)); |
||||||
|
|
||||||
|
// Set style
|
||||||
|
this.style = { |
||||||
|
...options.style, |
||||||
|
message: { ...options.style.message }, |
||||||
|
dot: { ...options.style.dot }, |
||||||
|
}; |
||||||
|
|
||||||
|
this.dotText = options.dotText; |
||||||
|
|
||||||
|
// Set callbacks
|
||||||
|
this.onClick = () => (options.onClick ? options.onClick(this) : undefined); |
||||||
|
this.onMessageClick = () => |
||||||
|
options.onMessageClick ? options.onMessageClick(this) : undefined; |
||||||
|
this.onMouseOver = () => |
||||||
|
options.onMouseOver ? options.onMouseOver(this) : undefined; |
||||||
|
this.onMouseOut = () => |
||||||
|
options.onMouseOut ? options.onMouseOut(this) : undefined; |
||||||
|
|
||||||
|
// Set custom renders
|
||||||
|
this.renderDot = options.renderDot; |
||||||
|
this.renderMessage = options.renderMessage; |
||||||
|
this.renderTooltip = options.renderTooltip; |
||||||
|
} |
||||||
|
|
||||||
|
public setPosition({ x, y }: { x: number; y: number }): this { |
||||||
|
this.x = x; |
||||||
|
this.y = y; |
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
|
public withDefaultColor(color: string): Commit<TNode> { |
||||||
|
const newStyle = { |
||||||
|
...this.style, |
||||||
|
dot: { ...this.style.dot }, |
||||||
|
message: { ...this.style.message }, |
||||||
|
}; |
||||||
|
|
||||||
|
if (!newStyle.color) newStyle.color = color; |
||||||
|
if (!newStyle.dot.color) newStyle.dot.color = color; |
||||||
|
if (!newStyle.message.color) newStyle.message.color = color; |
||||||
|
|
||||||
|
const commit = this.cloneCommit(); |
||||||
|
commit.style = newStyle; |
||||||
|
|
||||||
|
return commit; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Ideally, we want Commit to be a [Value Object](https://martinfowler.com/bliki/ValueObject.html).
|
||||||
|
* We started with a mutable class. So we'll refactor that little by little. |
||||||
|
* This private function is a helper to create a new Commit from existing one. |
||||||
|
*/ |
||||||
|
private cloneCommit() { |
||||||
|
const commit = new Commit({ |
||||||
|
author: `${this.author.name} <${this.author.email}>`, |
||||||
|
subject: this.subject, |
||||||
|
branch: this.branch, |
||||||
|
style: this.style, |
||||||
|
body: this.body, |
||||||
|
y: this.row, |
||||||
|
x: this.col, |
||||||
|
hash: this.hash, |
||||||
|
parents: this.parents, |
||||||
|
dotText: this.dotText, |
||||||
|
onClick: this.onClick, |
||||||
|
onMessageClick: this.onMessageClick, |
||||||
|
onMouseOver: this.onMouseOver, |
||||||
|
onMouseOut: this.onMouseOut, |
||||||
|
renderDot: this.renderDot, |
||||||
|
renderMessage: this.renderMessage, |
||||||
|
renderTooltip: this.renderTooltip, |
||||||
|
}); |
||||||
|
|
||||||
|
commit.x = this.x; |
||||||
|
commit.y = this.y; |
||||||
|
|
||||||
|
return commit; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,229 @@ |
|||||||
|
import type { TemplateOptions } from "./template"; |
||||||
|
import { Commit, type CommitRenderOptions, type CommitOptions } from "./commit"; |
||||||
|
|
||||||
|
import type { GitgraphCore } from "./gitgraph"; |
||||||
|
|
||||||
|
|
||||||
|
export { |
||||||
|
type GitgraphCommitOptions, |
||||||
|
GitgraphUserApi, |
||||||
|
}; |
||||||
|
|
||||||
|
interface GitgraphCommitOptions<TNode> extends CommitRenderOptions<TNode> { |
||||||
|
author?: string; |
||||||
|
subject?: string; |
||||||
|
body?: string; |
||||||
|
hash?: string; |
||||||
|
style?: TemplateOptions["commit"]; |
||||||
|
dotText?: string; |
||||||
|
tag?: string; |
||||||
|
onClick?: (commit: Commit<TNode>) => void; |
||||||
|
onMessageClick?: (commit: Commit<TNode>) => void; |
||||||
|
onMouseOver?: (commit: Commit<TNode>) => void; |
||||||
|
onMouseOut?: (commit: Commit<TNode>) => void; |
||||||
|
} |
||||||
|
|
||||||
|
class GitgraphUserApi<TNode> { |
||||||
|
// tslint:disable:variable-name - Prefix `_` = explicitly private for JS users
|
||||||
|
private _graph: GitgraphCore<TNode>; |
||||||
|
private _onGraphUpdate: () => void; |
||||||
|
// tslint:enable:variable-name
|
||||||
|
|
||||||
|
constructor(graph: GitgraphCore<TNode>, onGraphUpdate: () => void) { |
||||||
|
this._graph = graph; |
||||||
|
this._onGraphUpdate = onGraphUpdate; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear everything (as `rm -rf .git && git init`). |
||||||
|
*/ |
||||||
|
public clear(): this { |
||||||
|
this._graph.commits = []; |
||||||
|
this._onGraphUpdate(); |
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
|
public swimlanes(data: unknown) { |
||||||
|
|
||||||
|
const invalidData = new Error( |
||||||
|
"list of swimlanes is invalid", |
||||||
|
); |
||||||
|
|
||||||
|
if (!Array.isArray(data)) { |
||||||
|
throw invalidData; |
||||||
|
} |
||||||
|
|
||||||
|
const areDataValid = data.every((options) => { |
||||||
|
return ( |
||||||
|
typeof options === "string" || typeof options === "boolean" |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
if (!areDataValid) { |
||||||
|
throw invalidData; |
||||||
|
} |
||||||
|
|
||||||
|
this._graph.swimlanes = data; |
||||||
|
this._graph.last_on_swimlanes = Array.apply(null, Array(data.length)).map(function () {}) |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
public commit(data: unknown) { |
||||||
|
|
||||||
|
const areDataValid = ( |
||||||
|
typeof data === "object" && |
||||||
|
Array.isArray(data["parents"]) |
||||||
|
); |
||||||
|
|
||||||
|
if (!areDataValid) { |
||||||
|
throw new Error( |
||||||
|
"invalid commit", |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// let heads: string[];
|
||||||
|
// this._graph.swimlanes.forEach((branch, col) => {
|
||||||
|
// if (branch) {
|
||||||
|
// heads.push(this._graph.last_on_swimlanes[col]);
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
let branch; |
||||||
|
let lane; |
||||||
|
if (data["parents"].every((parents) => { |
||||||
|
return this._graph.last_on_swimlanes.includes(parents); |
||||||
|
})) { |
||||||
|
lane = Number.MAX_VALUE; |
||||||
|
data["parents"].forEach((parent) => { |
||||||
|
let new_lane = this._graph.last_on_swimlanes.indexOf(parent); |
||||||
|
if ( new_lane < lane ) { |
||||||
|
lane = new_lane; |
||||||
|
} |
||||||
|
}); |
||||||
|
branch = this._graph.swimlanes[lane]; |
||||||
|
if (!branch) { |
||||||
|
branch = data["hash"]; |
||||||
|
this._graph.swimlanes[lane] = branch; |
||||||
|
} |
||||||
|
this._graph.last_on_swimlanes[lane] = data["hash"]; |
||||||
|
} else { |
||||||
|
branch = data["hash"]; |
||||||
|
// this._graph.swimlanes.some((b, col) => {
|
||||||
|
// console.log("is empty? ",col,!b);
|
||||||
|
// if (!b) {
|
||||||
|
// lane = col;
|
||||||
|
// return true;
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// if (!lane) {
|
||||||
|
lane = this._graph.swimlanes.length; |
||||||
|
this._graph.swimlanes.push(branch); |
||||||
|
this._graph.last_on_swimlanes.push(branch); |
||||||
|
// } else {
|
||||||
|
// this._graph.swimlanes[lane] = branch;
|
||||||
|
// this._graph.last_on_swimlanes[lane] = branch;
|
||||||
|
// }
|
||||||
|
} |
||||||
|
|
||||||
|
data["parents"].forEach((parent) => { |
||||||
|
let r = this._graph.rows.getRowOf(parent); |
||||||
|
let c = this._graph.commits[r]; |
||||||
|
let b = c.branch; |
||||||
|
if (branch!=b) { |
||||||
|
this._graph.swimlanes.forEach((bb, col) => { |
||||||
|
if (bb == b) { |
||||||
|
this._graph.swimlanes[col] = undefined; |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// if (!this._graph.branches.has(branch)) {
|
||||||
|
// this._graph.createBranch({name:branch});
|
||||||
|
// }
|
||||||
|
|
||||||
|
data["branch"] = branch; |
||||||
|
data["x"] = lane; |
||||||
|
data["y"] = this._graph.commits.length; |
||||||
|
|
||||||
|
let options:CommitOptions<TNode> = { |
||||||
|
x: data["x"], |
||||||
|
y: data["y"], |
||||||
|
...data, |
||||||
|
style: { |
||||||
|
...this._graph.template.commit, |
||||||
|
message: { |
||||||
|
...this._graph.template.commit.message, |
||||||
|
display: this._graph.shouldDisplayCommitMessage, |
||||||
|
}, |
||||||
|
}, |
||||||
|
author: data["author"], |
||||||
|
subject: data["subject"], |
||||||
|
} |
||||||
|
let n = new Commit(options); |
||||||
|
this._graph.commits.push(n); |
||||||
|
|
||||||
|
this._onGraphUpdate(); |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Import a JSON. |
||||||
|
* |
||||||
|
* Data can't be typed since it comes from a JSON. |
||||||
|
* We validate input format and throw early if something is invalid. |
||||||
|
* |
||||||
|
* @experimental |
||||||
|
* @param data JSON from `git2json` output |
||||||
|
*/ |
||||||
|
public import(data: unknown) { |
||||||
|
const invalidData = new Error( |
||||||
|
"invalid history", |
||||||
|
); |
||||||
|
|
||||||
|
// We manually validate input data instead of using a lib like yup.
|
||||||
|
// => this is to keep bundlesize small.
|
||||||
|
|
||||||
|
if (!Array.isArray(data)) { |
||||||
|
throw invalidData; |
||||||
|
} |
||||||
|
|
||||||
|
const areDataValid = data.every((options) => { |
||||||
|
return ( |
||||||
|
typeof options === "object" |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
if (!areDataValid) { |
||||||
|
throw invalidData; |
||||||
|
} |
||||||
|
|
||||||
|
const commitOptionsList: Array< |
||||||
|
CommitOptions<TNode> & { refs: string[] } |
||||||
|
> = data |
||||||
|
.map((options) => ({ |
||||||
|
...options, |
||||||
|
style: { |
||||||
|
...this._graph.template.commit, |
||||||
|
message: { |
||||||
|
...this._graph.template.commit.message, |
||||||
|
display: this._graph.shouldDisplayCommitMessage, |
||||||
|
}, |
||||||
|
}, |
||||||
|
author: options.author, |
||||||
|
})); |
||||||
|
|
||||||
|
// Use validated `value`.
|
||||||
|
this.clear(); |
||||||
|
|
||||||
|
this._graph.commits = commitOptionsList.map( |
||||||
|
(options) => new Commit(options), |
||||||
|
); |
||||||
|
|
||||||
|
this._onGraphUpdate(); |
||||||
|
|
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
|
// tslint:enable:variable-name
|
||||||
|
} |
@ -0,0 +1,375 @@ |
|||||||
|
import { Branch } from "./branch"; |
||||||
|
import type { Commit } from "./commit"; |
||||||
|
import { createGraphRows, GraphRows } from "./graph-rows"; |
||||||
|
//import { BranchesOrder, CompareBranchesOrder } from "./branches-order";
|
||||||
|
import { |
||||||
|
Template, |
||||||
|
type TemplateOptions, |
||||||
|
TemplateName, |
||||||
|
getTemplate, |
||||||
|
} from "./template"; |
||||||
|
import { BranchesPathsCalculator, type BranchesPaths } from "./branches-paths"; |
||||||
|
import { booleanOptionOr, numberOptionOr } from "./utils"; |
||||||
|
import { |
||||||
|
GitgraphUserApi, |
||||||
|
} from "./gitgraph-user-api"; |
||||||
|
|
||||||
|
export { type GitgraphOptions, type RenderedData, GitgraphCore }; |
||||||
|
|
||||||
|
type Color = string; |
||||||
|
|
||||||
|
class BranchesOrder<TNode> { |
||||||
|
private branches: Map<Branch["name"], Branch<TNode>>; |
||||||
|
private colors: Color[]; |
||||||
|
public constructor( |
||||||
|
branches: Map<Branch["name"], Branch<TNode>>, |
||||||
|
colors: Color[], |
||||||
|
) { |
||||||
|
this.colors = colors; |
||||||
|
this.branches = branches; |
||||||
|
} |
||||||
|
/** |
||||||
|
* Return the order of the given branch name. |
||||||
|
* |
||||||
|
* @param branchName Name of the branch |
||||||
|
*/ |
||||||
|
public get(branchName: Branch["name"]): number { |
||||||
|
return this.branches.get(branchName).index; |
||||||
|
} |
||||||
|
|
||||||
|
public getColorOf(branchName: Branch["name"]): Color { |
||||||
|
return this.colors[this.get(branchName) % this.colors.length]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
interface GitgraphOptions { |
||||||
|
template?: TemplateName | Template; |
||||||
|
initCommitOffsetX?: number; |
||||||
|
initCommitOffsetY?: number; |
||||||
|
author?: string; |
||||||
|
branchLabelOnEveryCommit?: boolean; |
||||||
|
commitMessage?: string; |
||||||
|
generateCommitHash?: () => Commit["hash"]; |
||||||
|
} |
||||||
|
|
||||||
|
interface RenderedData<TNode> { |
||||||
|
commits: Array<Commit<TNode>>; |
||||||
|
branchesPaths: BranchesPaths<TNode>; |
||||||
|
commitMessagesX: number; |
||||||
|
} |
||||||
|
|
||||||
|
class GitgraphCore<TNode = SVGElement> { |
||||||
|
public get isHorizontal(): boolean { |
||||||
|
return false; |
||||||
|
} |
||||||
|
// public get isVertical(): boolean {
|
||||||
|
// return true;
|
||||||
|
// }
|
||||||
|
public get isReverse(): boolean { |
||||||
|
return true; |
||||||
|
} |
||||||
|
public get shouldDisplayCommitMessage(): boolean { |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
public initCommitOffsetX: number; |
||||||
|
public initCommitOffsetY: number; |
||||||
|
public author: string; |
||||||
|
public commitMessage: string; |
||||||
|
public template: Template; |
||||||
|
public rows: GraphRows<TNode>; |
||||||
|
|
||||||
|
public commits: Array<Commit<TNode>> = []; |
||||||
|
public swimlanes: Array<any> = []; |
||||||
|
public last_on_swimlanes: Array<string> = []; |
||||||
|
public branches: Map<Branch["name"], Branch<TNode>> = new Map(); |
||||||
|
|
||||||
|
private listeners: Array<(data: RenderedData<TNode>) => void> = []; |
||||||
|
private nextTimeoutId: number | null = null; |
||||||
|
|
||||||
|
constructor(options: GitgraphOptions = {}) { |
||||||
|
this.template = getTemplate(options.template); |
||||||
|
|
||||||
|
|
||||||
|
// Set all options with default values
|
||||||
|
this.initCommitOffsetX = numberOptionOr(options.initCommitOffsetX, 0); |
||||||
|
this.initCommitOffsetY = numberOptionOr(options.initCommitOffsetY, 0); |
||||||
|
this.author = options.author || ""; |
||||||
|
this.commitMessage = |
||||||
|
options.commitMessage || ""; |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Return the API to manipulate Gitgraph as a user. |
||||||
|
* Rendering library should give that API to their consumer. |
||||||
|
*/ |
||||||
|
public getUserApi(): GitgraphUserApi<TNode> { |
||||||
|
return new GitgraphUserApi(this, () => this.next()); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Add a change listener. |
||||||
|
* It will be called any time the graph have changed (commit, merge…). |
||||||
|
* |
||||||
|
* @param listener A callback to be invoked on every change. |
||||||
|
* @returns A function to remove this change listener. |
||||||
|
*/ |
||||||
|
public subscribe(listener: (data: RenderedData<TNode>) => void): () => void { |
||||||
|
this.listeners.push(listener); |
||||||
|
|
||||||
|
let isSubscribed = true; |
||||||
|
|
||||||
|
return () => { |
||||||
|
if (!isSubscribed) return; |
||||||
|
isSubscribed = false; |
||||||
|
const index = this.listeners.indexOf(listener); |
||||||
|
this.listeners.splice(index, 1); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Return all data required for rendering. |
||||||
|
* Rendering libraries will use this to implement their rendering strategy. |
||||||
|
*/ |
||||||
|
public getRenderedData(): RenderedData<TNode> { |
||||||
|
const commits = this.computeRenderedCommits(); |
||||||
|
const branchesPaths = this.computeRenderedBranchesPaths(commits); |
||||||
|
const commitMessagesX = this.computeCommitMessagesX(branchesPaths); |
||||||
|
|
||||||
|
this.computeBranchesColor(branchesPaths); |
||||||
|
|
||||||
|
return { commits, branchesPaths, commitMessagesX }; |
||||||
|
} |
||||||
|
|
||||||
|
public createBranch(args: any): Branch<TNode> { |
||||||
|
|
||||||
|
let options = { |
||||||
|
gitgraph: this, |
||||||
|
name: "", |
||||||
|
parentCommitHash: "", |
||||||
|
style: this.template.branch, |
||||||
|
onGraphUpdate: () => this.next(), |
||||||
|
}; |
||||||
|
|
||||||
|
args.style = args.style || {}; |
||||||
|
options = { |
||||||
|
...options, |
||||||
|
...args, |
||||||
|
style: { |
||||||
|
...options.style, |
||||||
|
...args.style, |
||||||
|
label: { |
||||||
|
...options.style.label, |
||||||
|
...args.style.label, |
||||||
|
}, |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
|
||||||
|
const branch = new Branch<TNode>(options); |
||||||
|
branch.index = this.branches.size; |
||||||
|
this.branches.set(branch.name, branch); |
||||||
|
return branch; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Return commits with data for rendering. |
||||||
|
*/ |
||||||
|
private computeRenderedCommits(): Array<Commit<TNode>> { |
||||||
|
//const branches = this.getBranches();
|
||||||
|
// // Commits that are not associated to a branch in `branches`
|
||||||
|
// // were in a deleted branch. If the latter was merged beforehand
|
||||||
|
// // they are reachable and are rendered. Others are not
|
||||||
|
// const reachableUnassociatedCommits = (() => {
|
||||||
|
// const unassociatedCommits = new Set(
|
||||||
|
// this.commits.reduce(
|
||||||
|
// (commits: Commit["hash"][], { hash }: { hash: Commit["hash"] }) =>
|
||||||
|
// !branches.has(hash) ? [...commits, hash] : commits,
|
||||||
|
// [],
|
||||||
|
// ),
|
||||||
|
// );
|
||||||
|
|
||||||
|
// const tipsOfMergedBranches = this.commits.reduce(
|
||||||
|
// (tipsOfMergedBranches: Commit<TNode>[], commit: Commit<TNode>) =>
|
||||||
|
// commit.parents.length > 1
|
||||||
|
// ? [
|
||||||
|
// ...tipsOfMergedBranches,
|
||||||
|
// ...commit.parents
|
||||||
|
// .slice(1)
|
||||||
|
// .map(
|
||||||
|
// (parentHash) =>
|
||||||
|
// this.commits.find(({ hash }) => parentHash === hash)!,
|
||||||
|
// ),
|
||||||
|
// ]
|
||||||
|
// : tipsOfMergedBranches,
|
||||||
|
// [],
|
||||||
|
// );
|
||||||
|
|
||||||
|
// const reachableCommits = new Set();
|
||||||
|
|
||||||
|
// tipsOfMergedBranches.forEach((tip) => {
|
||||||
|
// let currentCommit: Commit<TNode> | undefined = tip;
|
||||||
|
|
||||||
|
// while (currentCommit && unassociatedCommits.has(currentCommit.hash)) {
|
||||||
|
// reachableCommits.add(currentCommit.hash);
|
||||||
|
|
||||||
|
// currentCommit =
|
||||||
|
// currentCommit.parents.length > 0
|
||||||
|
// ? this.commits.find(
|
||||||
|
// ({ hash }) => currentCommit!.parents[0] === hash,
|
||||||
|
// )
|
||||||
|
// : undefined;
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
|
||||||
|
// return reachableCommits;
|
||||||
|
// })();
|
||||||
|
|
||||||
|
this.commits.forEach( |
||||||
|
({ branch, col, hash }) => { |
||||||
|
if (!this.branches.has(branch)) { |
||||||
|
this.createBranch({name:branch}); |
||||||
|
} |
||||||
|
this.last_on_swimlanes[col] = hash; |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
this.rows = createGraphRows(this.commits); |
||||||
|
const branchesOrder = new BranchesOrder<TNode>( |
||||||
|
this.branches, |
||||||
|
this.template.colors, |
||||||
|
); |
||||||
|
|
||||||
|
return ( |
||||||
|
this.commits |
||||||
|
.map((commit) => this.withPosition(commit)) |
||||||
|
// Fallback commit computed color on branch color.
|
||||||
|
.map((commit) => |
||||||
|
commit.withDefaultColor( |
||||||
|
this.getBranchDefaultColor(branchesOrder, commit.branch), |
||||||
|
), |
||||||
|
) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Return the default color for given branch. |
||||||
|
* |
||||||
|
* @param branchesOrder Computed order of branches |
||||||
|
* @param branchName Name of the branch |
||||||
|
*/ |
||||||
|
private getBranchDefaultColor( |
||||||
|
branchesOrder: BranchesOrder<TNode>, |
||||||
|
branchName: Branch["name"], |
||||||
|
): string { |
||||||
|
return branchesOrder.getColorOf(branchName); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Return branches paths with all data required for rendering. |
||||||
|
* |
||||||
|
* @param commits List of commits with rendering data computed |
||||||
|
*/ |
||||||
|
private computeRenderedBranchesPaths( |
||||||
|
commits: Array<Commit<TNode>>, |
||||||
|
): BranchesPaths<TNode> { |
||||||
|
return new BranchesPathsCalculator<TNode>( |
||||||
|
commits, |
||||||
|
this.rows, |
||||||
|
this.branches, |
||||||
|
this.isReverse, |
||||||
|
this.template.commit.spacing, |
||||||
|
).execute(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Set branches colors based on branches paths. |
||||||
|
* |
||||||
|
* @param commits List of graph commits |
||||||
|
* @param branchesPaths Branches paths to be rendered |
||||||
|
*/ |
||||||
|
private computeBranchesColor( |
||||||
|
branchesPaths: BranchesPaths<TNode>, |
||||||
|
): void { |
||||||
|
const branchesOrder = new BranchesOrder<TNode>( |
||||||
|
this.branches, |
||||||
|
this.template.colors, |
||||||
|
); |
||||||
|
Array.from(branchesPaths).forEach(([branch]) => { |
||||||
|
branch.computedColor = |
||||||
|
branch.style.color || |
||||||
|
this.getBranchDefaultColor(branchesOrder, branch.name); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Return commit messages X position for rendering. |
||||||
|
* |
||||||
|
* @param branchesPaths Branches paths to be rendered |
||||||
|
*/ |
||||||
|
private computeCommitMessagesX(branchesPaths: BranchesPaths<TNode>): number { |
||||||
|
return this.swimlanes.length * this.template.branch.spacing; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get all branches from current commits. |
||||||
|
*/ |
||||||
|
// private getBranches(): Map<Commit["hash"], Set<Branch["name"]>> {
|
||||||
|
// const result = new Map<Commit["hash"], Set<Branch["name"]>>();
|
||||||
|
// this.commits.forEach((commit) => {
|
||||||
|
// let r = new Set<Branch["name"]>();
|
||||||
|
// r.add(commit.branch);
|
||||||
|
// result.set(commit.hash, r);
|
||||||
|
// });
|
||||||
|
// return result;
|
||||||
|
// }
|
||||||
|
|
||||||
|
/** |
||||||
|
* Add position to given commit. |
||||||
|
* |
||||||
|
* @param rows Graph rows |
||||||
|
* @param branchesOrder Computed order of branches |
||||||
|
* @param commit Commit to position |
||||||
|
*/ |
||||||
|
private withPosition( |
||||||
|
commit: Commit<TNode>, |
||||||
|
): Commit<TNode> { |
||||||
|
//const row = rows.getRowOf(commit.hash);
|
||||||
|
const maxRow = this.rows.getMaxRow(); |
||||||
|
|
||||||
|
//const order = branchesOrder.get(commit.branch);
|
||||||
|
|
||||||
|
if (this.isReverse) { |
||||||
|
return commit.setPosition({ |
||||||
|
x: this.initCommitOffsetX + this.template.branch.spacing * commit.col, |
||||||
|
y: this.initCommitOffsetY + this.template.commit.spacing * (maxRow - commit.row), |
||||||
|
}); |
||||||
|
} |
||||||
|
else { |
||||||
|
return commit.setPosition({ |
||||||
|
x: this.initCommitOffsetX + this.template.branch.spacing * commit.col, |
||||||
|
y: this.initCommitOffsetY + this.template.commit.spacing * commit.row, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
/** |
||||||
|
* Tell each listener something new happened. |
||||||
|
* E.g. a rendering library will know it needs to re-render the graph. |
||||||
|
*/ |
||||||
|
private next() { |
||||||
|
if (this.nextTimeoutId) { |
||||||
|
window.clearTimeout(this.nextTimeoutId); |
||||||
|
} |
||||||
|
|
||||||
|
// Use setTimeout() with `0` to debounce call to next tick.
|
||||||
|
this.nextTimeoutId = window.setTimeout(() => { |
||||||
|
this.listeners.forEach((listener) => listener(this.getRenderedData())); |
||||||
|
}, 0); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,11 @@ |
|||||||
|
import type { Commit } from "./commit"; |
||||||
|
|
||||||
|
import { RegularGraphRows } from "./regular-graph-rows"; |
||||||
|
|
||||||
|
export { createGraphRows, RegularGraphRows as GraphRows }; |
||||||
|
|
||||||
|
function createGraphRows<TNode>( |
||||||
|
commits: Array<Commit<TNode>>, |
||||||
|
) { |
||||||
|
return new RegularGraphRows(commits); |
||||||
|
} |
@ -0,0 +1,11 @@ |
|||||||
|
export { GitgraphCore, type GitgraphOptions, type RenderedData } from "./gitgraph"; |
||||||
|
export { |
||||||
|
GitgraphUserApi, |
||||||
|
type GitgraphCommitOptions, |
||||||
|
} from "./gitgraph-user-api"; |
||||||
|
|
||||||
|
export { Branch } from "./branch"; |
||||||
|
export { Commit } from "./commit"; |
||||||
|
export { MergeStyle, TemplateName, templateExtend } from "./template"; |
||||||
|
export { type BranchesPaths, type Coordinate, toSvgPath } from "./branches-paths"; |
||||||
|
export { arrowSvgPath } from "./utils"; |
@ -0,0 +1,46 @@ |
|||||||
|
import type { Commit } from "./commit"; |
||||||
|
|
||||||
|
export class RegularGraphRows<TNode> { |
||||||
|
protected rows = new Map<Commit["hash"], number>(); |
||||||
|
|
||||||
|
private maxRowCache: number | undefined = undefined; |
||||||
|
|
||||||
|
public constructor(commits: Array<Commit<TNode>>) { |
||||||
|
this.computeRowsFromCommits(commits); |
||||||
|
} |
||||||
|
|
||||||
|
public getRowOf(commitHash: Commit["hash"]): number { |
||||||
|
return this.rows.get(commitHash) || 0; |
||||||
|
} |
||||||
|
|
||||||
|
public getMaxRow(): number { |
||||||
|
// if (this.maxRowCache === undefined) {
|
||||||
|
// this.maxRowCache = uniq(Array.from(this.rows.values())).length - 1;
|
||||||
|
// }
|
||||||
|
return this.maxRowCache; |
||||||
|
} |
||||||
|
|
||||||
|
protected computeRowsFromCommits(commits: Array<Commit<TNode>>): void { |
||||||
|
commits.forEach((commit, i) => { |
||||||
|
this.rows.set(commit.hash, i); |
||||||
|
}); |
||||||
|
this.maxRowCache = commits.length - 1; |
||||||
|
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Creates a duplicate-free version of an array. |
||||||
|
* |
||||||
|
* Don't use lodash's `uniq` as it increased bundlesize a lot for such a |
||||||
|
* simple function. |
||||||
|
* => The way we bundle for browser seems not to work with `lodash-es`. |
||||||
|
* => I didn't to get tree-shaking to work with `lodash` (the CommonJS version). |
||||||
|
* |
||||||
|
* @param array Array of values |
||||||
|
*/ |
||||||
|
// function uniq<T>(array: T[]): T[] {
|
||||||
|
// const set = new Set<T>();
|
||||||
|
// array.forEach((value) => set.add(value));
|
||||||
|
// return Array.from(set);
|
||||||
|
// }
|
@ -0,0 +1,464 @@ |
|||||||
|
import { booleanOptionOr, numberOptionOr } from "./utils"; |
||||||
|
|
||||||
|
export { |
||||||
|
MergeStyle, |
||||||
|
type ArrowStyle, |
||||||
|
type BranchStyle, |
||||||
|
type CommitDotStyle, |
||||||
|
type CommitMessageStyle, |
||||||
|
type CommitStyleBase, |
||||||
|
type CommitStyle, |
||||||
|
type TemplateOptions, |
||||||
|
Template, |
||||||
|
TemplateName, |
||||||
|
blackArrowTemplate, |
||||||
|
metroTemplate, |
||||||
|
templateExtend, |
||||||
|
getTemplate, |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* Branch merge style enum |
||||||
|
*/ |
||||||
|
enum MergeStyle { |
||||||
|
Bezier = "bezier", |
||||||
|
Straight = "straight", |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Arrow style |
||||||
|
*/ |
||||||
|
interface ArrowStyle { |
||||||
|
/** |
||||||
|
* Arrow color |
||||||
|
*/ |
||||||
|
color: string | null; |
||||||
|
/** |
||||||
|
* Arrow size in pixel |
||||||
|
*/ |
||||||
|
size: number | null; |
||||||
|
/** |
||||||
|
* Arrow offset in pixel |
||||||
|
*/ |
||||||
|
offset: number; |
||||||
|
} |
||||||
|
|
||||||
|
type ArrowStyleOptions = Partial<ArrowStyle>; |
||||||
|
|
||||||
|
interface BranchStyle { |
||||||
|
/** |
||||||
|
* Branch color |
||||||
|
*/ |
||||||
|
color?: string; |
||||||
|
/** |
||||||
|
* Branch line width in pixel |
||||||
|
*/ |
||||||
|
lineWidth: number; |
||||||
|
/** |
||||||
|
* Branch merge style |
||||||
|
*/ |
||||||
|
mergeStyle: MergeStyle; |
||||||
|
/** |
||||||
|
* Space between branches |
||||||
|
*/ |
||||||
|
spacing: number; |
||||||
|
/** |
||||||
|
* Branch label style |
||||||
|
*/ |
||||||
|
label: BranchLabelStyleOptions; |
||||||
|
} |
||||||
|
|
||||||
|
type BranchStyleOptions = Partial<BranchStyle>; |
||||||
|
|
||||||
|
interface BranchLabelStyle { |
||||||
|
/** |
||||||
|
* Branch label visibility |
||||||
|
*/ |
||||||
|
display: boolean; |
||||||
|
/** |
||||||
|
* Branch label text color |
||||||
|
*/ |
||||||
|
color: string; |
||||||
|
/** |
||||||
|
* Branch label stroke color |
||||||
|
*/ |
||||||
|
strokeColor: string; |
||||||
|
/** |
||||||
|
* Branch label background color |
||||||
|
*/ |
||||||
|
bgColor: string; |
||||||
|
/** |
||||||
|
* Branch label font |
||||||
|
*/ |
||||||
|
font: string; |
||||||
|
/** |
||||||
|
* Branch label border radius |
||||||
|
*/ |
||||||
|
borderRadius: number; |
||||||
|
} |
||||||
|
|
||||||
|
type BranchLabelStyleOptions = Partial<BranchLabelStyle>; |
||||||
|
|
||||||
|
export interface TagStyle { |
||||||
|
/** |
||||||
|
* Tag text color |
||||||
|
*/ |
||||||
|
color: string; |
||||||
|
/** |
||||||
|
* Tag stroke color |
||||||
|
*/ |
||||||
|
strokeColor?: string; |
||||||
|
/** |
||||||
|
* Tag background color |
||||||
|
*/ |
||||||
|
bgColor?: string; |
||||||
|
/** |
||||||
|
* Tag font |
||||||
|
*/ |
||||||
|
font: string; |
||||||
|
/** |
||||||
|
* Tag border radius |
||||||
|
*/ |
||||||
|
borderRadius: number; |
||||||
|
/** |
||||||
|
* Width of the tag pointer |
||||||
|
*/ |
||||||
|
pointerWidth: number; |
||||||
|
} |
||||||
|
|
||||||
|
type TagStyleOptions = Partial<TagStyle>; |
||||||
|
|
||||||
|
interface CommitDotStyle { |
||||||
|
/** |
||||||
|
* Commit dot color |
||||||
|
*/ |
||||||
|
color?: string; |
||||||
|
/** |
||||||
|
* Commit dot size in pixel |
||||||
|
*/ |
||||||
|
size: number; |
||||||
|
/** |
||||||
|
* Commit dot stroke width |
||||||
|
*/ |
||||||
|
strokeWidth?: number; |
||||||
|
/** |
||||||
|
* Commit dot stroke color |
||||||
|
*/ |
||||||
|
strokeColor?: string; |
||||||
|
/** |
||||||
|
* Commit dot font |
||||||
|
*/ |
||||||
|
font: string; |
||||||
|
} |
||||||
|
|
||||||
|
type CommitDotStyleOptions = Partial<CommitDotStyle>; |
||||||
|
|
||||||
|
interface CommitMessageStyle { |
||||||
|
/** |
||||||
|
* Commit message color |
||||||
|
*/ |
||||||
|
color?: string; |
||||||
|
/** |
||||||
|
* Commit message display policy |
||||||
|
*/ |
||||||
|
display: boolean; |
||||||
|
/** |
||||||
|
* Commit message author display policy |
||||||
|
*/ |
||||||
|
displayAuthor: boolean; |
||||||
|
/** |
||||||
|
* Commit message hash display policy |
||||||
|
*/ |
||||||
|
displayHash: boolean; |
||||||
|
/** |
||||||
|
* Commit message font |
||||||
|
*/ |
||||||
|
font: string; |
||||||
|
} |
||||||
|
|
||||||
|
type CommitMessageStyleOptions = Partial<CommitMessageStyle>; |
||||||
|
|
||||||
|
interface CommitStyleBase { |
||||||
|
/** |
||||||
|
* Spacing between commits |
||||||
|
*/ |
||||||
|
spacing: number; |
||||||
|
/** |
||||||
|
* Commit color (dot & message) |
||||||
|
*/ |
||||||
|
color?: string; |
||||||
|
/** |
||||||
|
* Tooltips policy |
||||||
|
*/ |
||||||
|
hasTooltipInCompactMode: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
interface CommitStyle extends CommitStyleBase { |
||||||
|
/** |
||||||
|
* Commit message style |
||||||
|
*/ |
||||||
|
message: CommitMessageStyle; |
||||||
|
/** |
||||||
|
* Commit dot style |
||||||
|
*/ |
||||||
|
dot: CommitDotStyle; |
||||||
|
} |
||||||
|
|
||||||
|
interface CommitStyleOptions extends Partial<CommitStyleBase> { |
||||||
|
/** |
||||||
|
* Commit message style |
||||||
|
*/ |
||||||
|
message?: CommitMessageStyleOptions; |
||||||
|
/** |
||||||
|
* Commit dot style |
||||||
|
*/ |
||||||
|
dot?: CommitDotStyleOptions; |
||||||
|
} |
||||||
|
|
||||||
|
interface TemplateOptions { |
||||||
|
/** |
||||||
|
* Colors scheme: One color for each column |
||||||
|
*/ |
||||||
|
colors?: string[]; |
||||||
|
/** |
||||||
|
* Arrow style |
||||||
|
*/ |
||||||
|
arrow?: ArrowStyleOptions; |
||||||
|
/** |
||||||
|
* Branch style |
||||||
|
*/ |
||||||
|
branch?: BranchStyleOptions; |
||||||
|
/** |
||||||
|
* Commit style |
||||||
|
*/ |
||||||
|
commit?: CommitStyleOptions; |
||||||
|
/** |
||||||
|
* Tag style |
||||||
|
*/ |
||||||
|
tag?: TagStyleOptions; |
||||||
|
} |
||||||
|
|
||||||
|
export const DEFAULT_FONT = "normal 12pt Calibri"; |
||||||
|
|
||||||
|
/** |
||||||
|
* Gitgraph template |
||||||
|
* |
||||||
|
* Set of design rules for the rendering. |
||||||
|
*/ |
||||||
|
class Template { |
||||||
|
/** |
||||||
|
* Colors scheme: One color for each column |
||||||
|
*/ |
||||||
|
public colors: string[]; |
||||||
|
/** |
||||||
|
* Arrow style |
||||||
|
*/ |
||||||
|
public arrow: ArrowStyle; |
||||||
|
/** |
||||||
|
* Branch style |
||||||
|
*/ |
||||||
|
public branch: BranchStyle; |
||||||
|
/** |
||||||
|
* Commit style |
||||||
|
*/ |
||||||
|
public commit: CommitStyle; |
||||||
|
/** |
||||||
|
* Tag style |
||||||
|
*/ |
||||||
|
public tag: TagStyleOptions; |
||||||
|
|
||||||
|
constructor(options: TemplateOptions) { |
||||||
|
// Options
|
||||||
|
options.branch = options.branch || {}; |
||||||
|
options.branch.label = options.branch.label || {}; |
||||||
|
options.arrow = options.arrow || {}; |
||||||
|
options.commit = options.commit || {}; |
||||||
|
options.commit.dot = options.commit.dot || {}; |
||||||
|
options.commit.message = options.commit.message || {}; |
||||||
|
|
||||||
|
// One color per column
|
||||||
|
this.colors = options.colors || ["#000000"]; |
||||||
|
|
||||||
|
// Branch style
|
||||||
|
this.branch = { |
||||||
|
color: options.branch.color, |
||||||
|
lineWidth: options.branch.lineWidth || 2, |
||||||
|
mergeStyle: options.branch.mergeStyle || MergeStyle.Bezier, |
||||||
|
spacing: numberOptionOr(options.branch.spacing, 20), |
||||||
|
label: { |
||||||
|
display: booleanOptionOr(options.branch.label.display, true), |
||||||
|
color: options.branch.label.color || options.commit.color, |
||||||
|
strokeColor: options.branch.label.strokeColor || options.commit.color, |
||||||
|
bgColor: options.branch.label.bgColor || "white", |
||||||
|
font: |
||||||
|
options.branch.label.font || |
||||||
|
options.commit.message.font || |
||||||
|
DEFAULT_FONT, |
||||||
|
borderRadius: numberOptionOr(options.branch.label.borderRadius, 10), |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
// Arrow style
|
||||||
|
this.arrow = { |
||||||
|
size: options.arrow.size || null, |
||||||
|
color: options.arrow.color || null, |
||||||
|
offset: options.arrow.offset || 2, |
||||||
|
}; |
||||||
|
|
||||||
|
// Commit style
|
||||||
|
this.commit = { |
||||||
|
color: options.commit.color, |
||||||
|
spacing: numberOptionOr(options.commit.spacing, 25), |
||||||
|
hasTooltipInCompactMode: booleanOptionOr( |
||||||
|
options.commit.hasTooltipInCompactMode, |
||||||
|
true, |
||||||
|
), |
||||||
|
dot: { |
||||||
|
color: options.commit.dot.color || options.commit.color, |
||||||
|
size: options.commit.dot.size || 3, |
||||||
|
strokeWidth: numberOptionOr(options.commit.dot.strokeWidth, 0), |
||||||
|
strokeColor: options.commit.dot.strokeColor, |
||||||
|
font: |
||||||
|
options.commit.dot.font || |
||||||
|
options.commit.message.font || |
||||||
|
"normal 10pt Calibri", |
||||||
|
}, |
||||||
|
message: { |
||||||
|
display: booleanOptionOr(options.commit.message.display, true), |
||||||
|
displayAuthor: booleanOptionOr( |
||||||
|
options.commit.message.displayAuthor, |
||||||
|
true, |
||||||
|
), |
||||||
|
displayHash: booleanOptionOr(options.commit.message.displayHash, true), |
||||||
|
color: options.commit.message.color || options.commit.color, |
||||||
|
font: options.commit.message.font || DEFAULT_FONT, |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
// Tag style
|
||||||
|
// This one is computed in the Tag instance. It needs Commit style
|
||||||
|
// that is partially computed at runtime (for colors).
|
||||||
|
this.tag = options.tag || {}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Black arrow template |
||||||
|
*/ |
||||||
|
const blackArrowTemplate = new Template({ |
||||||
|
colors: ["#6963FF", "#47E8D4", "#6BDB52", "#E84BA5", "#FFA657"], |
||||||
|
branch: { |
||||||
|
color: "#000000", |
||||||
|
lineWidth: 4, |
||||||
|
spacing: 50, |
||||||
|
mergeStyle: MergeStyle.Straight, |
||||||
|
}, |
||||||
|
commit: { |
||||||
|
spacing: 60, |
||||||
|
dot: { |
||||||
|
size: 16, |
||||||
|
strokeColor: "#000000", |
||||||
|
strokeWidth: 4, |
||||||
|
}, |
||||||
|
message: { |
||||||
|
color: "black", |
||||||
|
}, |
||||||
|
}, |
||||||
|
arrow: { |
||||||
|
size: 16, |
||||||
|
offset: -1.5, |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* Metro template |
||||||
|
*/ |
||||||
|
const metroTemplate = new Template({ |
||||||
|
colors: ["#4972A5","#00ACC1","#F06292","#D32F2F", "#FF5722", "#AB47BC", "#4CAF50", "#FFB300","#008fb5", "#f1c109","#00796B","#A1887F","#616161"], |
||||||
|
branch: { |
||||||
|
lineWidth: 10, |
||||||
|
spacing: 50, |
||||||
|
}, |
||||||
|
commit: { |
||||||
|
spacing: 80, |
||||||
|
dot: { |
||||||
|
size: 14, |
||||||
|
}, |
||||||
|
message: { |
||||||
|
font: "normal 14pt Arial", |
||||||
|
}, |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
enum TemplateName { |
||||||
|
Metro = "metro", |
||||||
|
BlackArrow = "blackarrow", |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extend an existing template with new options. |
||||||
|
* |
||||||
|
* @param selectedTemplate Template to extend |
||||||
|
* @param options Template options |
||||||
|
*/ |
||||||
|
function templateExtend( |
||||||
|
selectedTemplate: TemplateName, |
||||||
|
options: TemplateOptions, |
||||||
|
): Template { |
||||||
|
const template = getTemplate(selectedTemplate); |
||||||
|
|
||||||
|
if (!options.branch) options.branch = {}; |
||||||
|
if (!options.commit) options.commit = {}; |
||||||
|
|
||||||
|
// This is tedious, but it seems acceptable so we don't need lodash
|
||||||
|
// as we want to keep bundlesize small.
|
||||||
|
return { |
||||||
|
colors: options.colors || template.colors, |
||||||
|
arrow: { |
||||||
|
...template.arrow, |
||||||
|
...options.arrow, |
||||||
|
}, |
||||||
|
branch: { |
||||||
|
...template.branch, |
||||||
|
...options.branch, |
||||||
|
label: { |
||||||
|
...template.branch.label, |
||||||
|
...options.branch.label, |
||||||
|
}, |
||||||
|
}, |
||||||
|
commit: { |
||||||
|
...template.commit, |
||||||
|
...options.commit, |
||||||
|
dot: { |
||||||
|
...template.commit.dot, |
||||||
|
...options.commit.dot, |
||||||
|
}, |
||||||
|
message: { |
||||||
|
...template.commit.message, |
||||||
|
...options.commit.message, |
||||||
|
}, |
||||||
|
}, |
||||||
|
tag: { |
||||||
|
...template.tag, |
||||||
|
...options.tag, |
||||||
|
}, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Resolve the template to use regarding given `template` value. |
||||||
|
* |
||||||
|
* @param template Selected template name, or instance. |
||||||
|
*/ |
||||||
|
function getTemplate(template?: TemplateName | Template): Template { |
||||||
|
if (!template) return metroTemplate; |
||||||
|
|
||||||
|
if (typeof template === "string") { |
||||||
|
return { |
||||||
|
[TemplateName.BlackArrow]: blackArrowTemplate, |
||||||
|
[TemplateName.Metro]: metroTemplate, |
||||||
|
}[template]; |
||||||
|
} |
||||||
|
|
||||||
|
return template as Template; |
||||||
|
} |
@ -0,0 +1,489 @@ |
|||||||
|
import { |
||||||
|
GitgraphCore, |
||||||
|
type GitgraphOptions, |
||||||
|
Commit, |
||||||
|
type GitgraphCommitOptions, |
||||||
|
type RenderedData, |
||||||
|
MergeStyle, |
||||||
|
arrowSvgPath, |
||||||
|
toSvgPath, |
||||||
|
type Coordinate, |
||||||
|
TemplateName, |
||||||
|
templateExtend, |
||||||
|
} from "../gitgraph-core"; |
||||||
|
|
||||||
|
import { |
||||||
|
createSvg, |
||||||
|
createG, |
||||||
|
createText, |
||||||
|
createCircle, |
||||||
|
createUse, |
||||||
|
createPath, |
||||||
|
createClipPath, |
||||||
|
createDefs, |
||||||
|
createForeignObject, |
||||||
|
} from "./svg-elements"; |
||||||
|
|
||||||
|
import { createTooltip, PADDING as TOOLTIP_PADDING } from "./tooltip"; |
||||||
|
|
||||||
|
type CommitOptions = GitgraphCommitOptions<SVGElement>; |
||||||
|
|
||||||
|
export { |
||||||
|
createGitgraph, |
||||||
|
TemplateName, |
||||||
|
templateExtend, |
||||||
|
MergeStyle, |
||||||
|
}; |
||||||
|
|
||||||
|
interface CommitYWithOffsets { |
||||||
|
[key: number]: number; |
||||||
|
} |
||||||
|
|
||||||
|
function createGitgraph( |
||||||
|
graphContainer: HTMLElement, |
||||||
|
options?: GitgraphOptions & { responsive?: boolean }, |
||||||
|
) { |
||||||
|
let commitsElements: { |
||||||
|
[commitHash: string]: { |
||||||
|
message: SVGGElement | null; |
||||||
|
}; |
||||||
|
} = {}; |
||||||
|
// Store a map to replace commits y with the correct value,
|
||||||
|
// including the message offset. Allows custom, flexible message height.
|
||||||
|
// E.g. {20: 30} means for commit: y=20 -> y=30
|
||||||
|
// Offset should be computed when graph is rendered (componentDidUpdate).
|
||||||
|
let commitYWithOffsets: CommitYWithOffsets = {}; |
||||||
|
let shouldRecomputeOffsets = false; |
||||||
|
let lastData: RenderedData<SVGElement>; |
||||||
|
let $commits: SVGElement; |
||||||
|
let commitMessagesX = 0; |
||||||
|
let $tooltip: SVGElement | null = null; |
||||||
|
|
||||||
|
// Create an `svg` context in which we'll render the graph.
|
||||||
|
const svg = createSvg(); |
||||||
|
adaptSvgOnUpdate(Boolean(options && options.responsive)); |
||||||
|
graphContainer.appendChild(svg); |
||||||
|
|
||||||
|
if (options && options.responsive) { |
||||||
|
graphContainer.setAttribute( |
||||||
|
"style", |
||||||
|
"display:inline-block; position: relative; width:100%; padding-bottom:100%; vertical-align:middle; overflow:hidden;", |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// React on gitgraph updates to re-render the graph.
|
||||||
|
const gitgraph = new GitgraphCore(options); |
||||||
|
gitgraph.subscribe((data) => { |
||||||
|
shouldRecomputeOffsets = true; |
||||||
|
render(data); |
||||||
|
}); |
||||||
|
|
||||||
|
// Return usable API for end-user.
|
||||||
|
return gitgraph.getUserApi(); |
||||||
|
|
||||||
|
function render(data: RenderedData<SVGElement>): void { |
||||||
|
// Reset before new rendering to flush previous state.
|
||||||
|
commitsElements = {}; |
||||||
|
|
||||||
|
const { commits, branchesPaths } = data; |
||||||
|
commitMessagesX = data.commitMessagesX; |
||||||
|
|
||||||
|
// Store data so we can re-render after offsets are computed.
|
||||||
|
lastData = data; |
||||||
|
|
||||||
|
// Store $commits so we can compute offsets from actual height.
|
||||||
|
$commits = renderCommits(commits); |
||||||
|
|
||||||
|
// Reset SVG with new content.
|
||||||
|
svg.innerHTML = ""; |
||||||
|
svg.appendChild( |
||||||
|
createG({ |
||||||
|
// Translate graph left => left-most branch label is not cropped (horizontal)
|
||||||
|
// Translate graph down => top-most commit tooltip is not cropped
|
||||||
|
translate: { x: 0, y: TOOLTIP_PADDING }, |
||||||
|
children: [renderBranchesPaths(branchesPaths), $commits], |
||||||
|
}), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
function adaptSvgOnUpdate(adaptToContainer: boolean): void { |
||||||
|
const observer = new MutationObserver(() => { |
||||||
|
if (shouldRecomputeOffsets) { |
||||||
|
shouldRecomputeOffsets = false; |
||||||
|
computeOffsets(); |
||||||
|
render(lastData); |
||||||
|
} else { |
||||||
|
positionCommitsElements(); |
||||||
|
adaptGraphDimensions(adaptToContainer); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
observer.observe(svg, { |
||||||
|
attributes: false, |
||||||
|
// Listen to subtree changes to react when we append the tooltip.
|
||||||
|
subtree: true, |
||||||
|
childList: true, |
||||||
|
}); |
||||||
|
|
||||||
|
function computeOffsets(): void { |
||||||
|
const commits: Element[] = Array.from($commits.children); |
||||||
|
let totalOffsetY = 0; |
||||||
|
|
||||||
|
// In VerticalReverse orientation, commits are in the same order in the DOM.
|
||||||
|
const orientedCommits = commits; |
||||||
|
|
||||||
|
commitYWithOffsets = orientedCommits.reduce<CommitYWithOffsets>( |
||||||
|
(newOffsets, commit) => { |
||||||
|
const commitY = parseInt( |
||||||
|
commit.getAttribute("transform")!.split(",")[1].slice(0, -1), |
||||||
|
10, |
||||||
|
); |
||||||
|
|
||||||
|
const firstForeignObject = commit.getElementsByTagName( |
||||||
|
"foreignObject", |
||||||
|
)[0]; |
||||||
|
const customHtmlMessage = |
||||||
|
firstForeignObject && firstForeignObject.firstElementChild; |
||||||
|
|
||||||
|
newOffsets[commitY] = commitY + totalOffsetY; |
||||||
|
|
||||||
|
// Increment total offset after setting the offset
|
||||||
|
// => offset next commits accordingly.
|
||||||
|
totalOffsetY += getMessageHeight(customHtmlMessage); |
||||||
|
|
||||||
|
return newOffsets; |
||||||
|
}, |
||||||
|
{}, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
function positionCommitsElements(): void { |
||||||
|
if (gitgraph.isHorizontal) { |
||||||
|
// Elements don't appear on horizontal mode, yet.
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const padding = 10; |
||||||
|
|
||||||
|
// Ensure commits elements (branch labels, message…) are well positionned.
|
||||||
|
// It can't be done at render time since elements size is dynamic.
|
||||||
|
Object.keys(commitsElements).forEach((commitHash) => { |
||||||
|
const { message } = commitsElements[commitHash]; |
||||||
|
|
||||||
|
// We'll store X position progressively and translate elements.
|
||||||
|
let x = commitMessagesX; |
||||||
|
|
||||||
|
if (message) { |
||||||
|
moveElement(message, x); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function adaptGraphDimensions(adaptToContainer: boolean): void { |
||||||
|
const { height, width } = svg.getBBox(); |
||||||
|
|
||||||
|
// FIXME: In horizontal mode, we mimic @gitgraph/react behavior
|
||||||
|
// => it gets re-rendered after offsets are computed
|
||||||
|
// => it applies paddings twice!
|
||||||
|
//
|
||||||
|
// It works… by chance. Technically, we should compute what would
|
||||||
|
// *actually* go beyond the computed limits of the graph.
|
||||||
|
const horizontalCustomOffset = 50; |
||||||
|
const verticalCustomOffset = 20; |
||||||
|
|
||||||
|
const widthOffset = // Add `TOOLTIP_PADDING` so we don't crop the tooltip text.
|
||||||
|
|
||||||
|
TOOLTIP_PADDING; |
||||||
|
|
||||||
|
const heightOffset =
|
||||||
|
// Add `TOOLTIP_PADDING` so we don't crop tooltip text
|
||||||
|
TOOLTIP_PADDING + verticalCustomOffset; |
||||||
|
|
||||||
|
if (adaptToContainer) { |
||||||
|
svg.setAttribute("preserveAspectRatio", "xMinYMin meet"); |
||||||
|
svg.setAttribute( |
||||||
|
"viewBox", |
||||||
|
`0 0 ${width + widthOffset} ${height + heightOffset}`, |
||||||
|
); |
||||||
|
} else { |
||||||
|
svg.setAttribute("width", (width + widthOffset).toString()); |
||||||
|
svg.setAttribute("height", (height + heightOffset).toString()); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function moveElement(target: Element, x: number): void { |
||||||
|
const transform = target.getAttribute("transform") || "translate(0, 0)"; |
||||||
|
target.setAttribute( |
||||||
|
"transform", |
||||||
|
transform.replace(/translate\(([\d\.]+),/, `translate(${x},`), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
function renderBranchesPaths( |
||||||
|
branchesPaths: RenderedData<SVGElement>["branchesPaths"], |
||||||
|
): SVGElement { |
||||||
|
const offset = gitgraph.template.commit.dot.size; |
||||||
|
const isBezier = gitgraph.template.branch.mergeStyle === MergeStyle.Bezier; |
||||||
|
|
||||||
|
const paths = Array.from(branchesPaths).map(([branch, coordinates]) => { |
||||||
|
return createPath({ |
||||||
|
d: toSvgPath( |
||||||
|
coordinates.map((coordinate) => coordinate.map(getWithCommitOffset)), |
||||||
|
isBezier, |
||||||
|
//gitgraph.isVertical,
|
||||||
|
), |
||||||
|
fill: "none", |
||||||
|
stroke: branch.computedColor || "", |
||||||
|
strokeWidth: branch.style.lineWidth, |
||||||
|
translate: { |
||||||
|
x: offset, |
||||||
|
y: offset, |
||||||
|
}, |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
return createG({ children: paths }); |
||||||
|
} |
||||||
|
|
||||||
|
function renderCommits(commits: Commit[]): SVGGElement { |
||||||
|
return createG({ children: commits.map(renderCommit) }); |
||||||
|
|
||||||
|
function renderCommit(commit: Commit): SVGGElement { |
||||||
|
const { x, y } = getWithCommitOffset(commit); |
||||||
|
|
||||||
|
return createG({ |
||||||
|
translate: { x, y }, |
||||||
|
children: [ |
||||||
|
renderDot(commit), |
||||||
|
...renderArrows(commit), |
||||||
|
|
||||||
|
createG({ |
||||||
|
translate: { x: -x, y: 0 }, |
||||||
|
children: [ |
||||||
|
renderMessage(commit), |
||||||
|
], |
||||||
|
}), |
||||||
|
], |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function renderArrows(commit: Commit): Array<SVGElement | null> { |
||||||
|
if (!gitgraph.template.arrow.size) { |
||||||
|
return [null]; |
||||||
|
} |
||||||
|
|
||||||
|
const commitRadius = commit.style.dot.size; |
||||||
|
|
||||||
|
return commit.parents.map((parentHash) => { |
||||||
|
const parent = commits.find(({ hash }) => hash === parentHash); |
||||||
|
if (!parent) return null; |
||||||
|
|
||||||
|
// Starting point, relative to commit
|
||||||
|
const origin = { x: commitRadius, y: commitRadius }; |
||||||
|
|
||||||
|
const path = createPath({ |
||||||
|
d: arrowSvgPath(gitgraph, parent, commit), |
||||||
|
fill: gitgraph.template.arrow.color || "", |
||||||
|
}); |
||||||
|
|
||||||
|
return createG({ translate: origin, children: [path] }); |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function renderMessage(commit: Commit): SVGElement | null { |
||||||
|
if (!commit.style.message.display) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
let message; |
||||||
|
|
||||||
|
if (commit.renderMessage) { |
||||||
|
message = createG({ children: [] }); |
||||||
|
|
||||||
|
// Add message after observer is set up => react based on body height.
|
||||||
|
// We might refactor it by including `onChildrenUpdate()` to `createG()`.
|
||||||
|
adaptMessageBodyHeight(message); |
||||||
|
message.appendChild(commit.renderMessage(commit)); |
||||||
|
|
||||||
|
setMessageRef(commit, message); |
||||||
|
|
||||||
|
return message; |
||||||
|
} |
||||||
|
|
||||||
|
const text = createText({ |
||||||
|
content: commit.message, |
||||||
|
fill: commit.style.message.color || "", |
||||||
|
font: commit.style.message.font, |
||||||
|
onClick: commit.onMessageClick, |
||||||
|
}); |
||||||
|
|
||||||
|
message = createG({ |
||||||
|
translate: { x: 0, y: commit.style.dot.size }, |
||||||
|
children: [text], |
||||||
|
}); |
||||||
|
|
||||||
|
if (commit.body) { |
||||||
|
const body = createForeignObject({ |
||||||
|
width: 600, |
||||||
|
translate: { x: 10, y: 0 }, |
||||||
|
content: commit.body, |
||||||
|
}); |
||||||
|
|
||||||
|
// Add message after observer is set up => react based on body height.
|
||||||
|
// We might refactor it by including `onChildrenUpdate()` to `createG()`.
|
||||||
|
adaptMessageBodyHeight(message); |
||||||
|
message.appendChild(body); |
||||||
|
} |
||||||
|
|
||||||
|
setMessageRef(commit, message); |
||||||
|
|
||||||
|
return message; |
||||||
|
} |
||||||
|
|
||||||
|
function adaptMessageBodyHeight(message: SVGElement): void { |
||||||
|
const observer = new MutationObserver((mutations) => { |
||||||
|
mutations.forEach(({ target }) => setChildrenForeignObjectHeight(target)); |
||||||
|
}); |
||||||
|
|
||||||
|
observer.observe(message, { |
||||||
|
attributes: false, |
||||||
|
subtree: false, |
||||||
|
childList: true, |
||||||
|
}); |
||||||
|
|
||||||
|
function setChildrenForeignObjectHeight(node: Node): void { |
||||||
|
if (node.nodeName === "foreignObject") { |
||||||
|
// We have to access the first child's parentElement to retrieve
|
||||||
|
// the Element instead of the Node => we can compute dimensions.
|
||||||
|
const foreignObject = node.firstChild && node.firstChild.parentElement; |
||||||
|
if (!foreignObject) return; |
||||||
|
|
||||||
|
// Force the height of the foreignObject (browser issue)
|
||||||
|
foreignObject.setAttribute( |
||||||
|
"height", |
||||||
|
getMessageHeight(foreignObject.firstElementChild).toString(), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
node.childNodes.forEach(setChildrenForeignObjectHeight); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function renderDot(commit: Commit): SVGElement { |
||||||
|
if (commit.renderDot) { |
||||||
|
return commit.renderDot(commit); |
||||||
|
} |
||||||
|
|
||||||
|
/* |
||||||
|
In order to handle strokes, we need to do some complex stuff here… 😅 |
||||||
|
|
||||||
|
Problem: strokes are drawn inside & outside the circle. |
||||||
|
But we want the stroke to be drawn inside only! |
||||||
|
|
||||||
|
The outside overlaps with other elements, as we expect the dot to have a fixed size. So we want to crop the outside part. |
||||||
|
|
||||||
|
Solution: |
||||||
|
1. Create the circle in a <defs> |
||||||
|
2. Define a clip path that references the circle |
||||||
|
3. Use the clip path, adding the stroke. |
||||||
|
4. Double stroke width as half of it will be clipped (the outside part). |
||||||
|
|
||||||
|
Ref.: https://stackoverflow.com/a/32162431/3911841
|
||||||
|
|
||||||
|
P.S. there is a proposal for a stroke-alignment property, |
||||||
|
but it's still a W3C Draft ¯\_(ツ)_/¯ |
||||||
|
https://svgwg.org/specs/strokes/#SpecifyingStrokeAlignment
|
||||||
|
*/ |
||||||
|
const circleId = commit.hash; |
||||||
|
const circle = createCircle({ |
||||||
|
id: circleId, |
||||||
|
radius: commit.style.dot.size, |
||||||
|
fill: commit.style.dot.color || "", |
||||||
|
}); |
||||||
|
|
||||||
|
const clipPathId = `clip-${commit.hash}`; |
||||||
|
const circleClipPath = createClipPath(); |
||||||
|
circleClipPath.setAttribute("id", clipPathId); |
||||||
|
circleClipPath.appendChild(createUse(circleId)); |
||||||
|
|
||||||
|
const useCirclePath = createUse(circleId); |
||||||
|
useCirclePath.setAttribute("clip-path", `url(#${clipPathId})`); |
||||||
|
useCirclePath.setAttribute("stroke", commit.style.dot.strokeColor || ""); |
||||||
|
const strokeWidth = commit.style.dot.strokeWidth |
||||||
|
? commit.style.dot.strokeWidth * 2 |
||||||
|
: 0; |
||||||
|
useCirclePath.setAttribute("stroke-width", strokeWidth.toString()); |
||||||
|
|
||||||
|
const dotText = commit.dotText |
||||||
|
? createText({ |
||||||
|
content: commit.dotText, |
||||||
|
font: commit.style.dot.font, |
||||||
|
anchor: "middle", |
||||||
|
translate: { x: commit.style.dot.size, y: commit.style.dot.size }, |
||||||
|
}) |
||||||
|
: null; |
||||||
|
|
||||||
|
return createG({ |
||||||
|
onClick: commit.onClick, |
||||||
|
onMouseOver: () => { |
||||||
|
appendTooltipToGraph(commit); |
||||||
|
commit.onMouseOver(); |
||||||
|
}, |
||||||
|
onMouseOut: () => { |
||||||
|
if ($tooltip) $tooltip.remove(); |
||||||
|
commit.onMouseOut(); |
||||||
|
}, |
||||||
|
children: [createDefs([circle, circleClipPath]), useCirclePath, dotText], |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function appendTooltipToGraph(commit: Commit): void { |
||||||
|
if (!svg.firstChild) return; |
||||||
|
|
||||||
|
const tooltip = commit.renderTooltip |
||||||
|
? commit.renderTooltip(commit) |
||||||
|
: createTooltip(commit); |
||||||
|
|
||||||
|
$tooltip = createG({ |
||||||
|
translate: getWithCommitOffset(commit), |
||||||
|
children: [tooltip], |
||||||
|
}); |
||||||
|
|
||||||
|
svg.firstChild.appendChild($tooltip); |
||||||
|
} |
||||||
|
|
||||||
|
function getWithCommitOffset({ x, y }: Coordinate): Coordinate { |
||||||
|
return { x, y: commitYWithOffsets[y] || y }; |
||||||
|
} |
||||||
|
|
||||||
|
function setMessageRef(commit: Commit, message: SVGGElement | null): void { |
||||||
|
if (!commitsElements[commit.hashAbbrev]) { |
||||||
|
initCommitElements(commit); |
||||||
|
} |
||||||
|
|
||||||
|
commitsElements[commit.hashAbbrev].message = message; |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
function initCommitElements(commit: Commit): void { |
||||||
|
commitsElements[commit.hashAbbrev] = { |
||||||
|
message: null, |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function getMessageHeight(message: Element | null): number { |
||||||
|
let messageHeight = 0; |
||||||
|
|
||||||
|
if (message) { |
||||||
|
const height = message.getBoundingClientRect().height; |
||||||
|
const marginTopInPx = window.getComputedStyle(message).marginTop || "0px"; |
||||||
|
const marginTop = parseInt(marginTopInPx.replace("px", ""), 10); |
||||||
|
|
||||||
|
messageHeight = height + marginTop; |
||||||
|
} |
||||||
|
|
||||||
|
return messageHeight; |
||||||
|
} |
@ -0,0 +1,274 @@ |
|||||||
|
export { |
||||||
|
createSvg, |
||||||
|
createG, |
||||||
|
createText, |
||||||
|
createCircle, |
||||||
|
createRect, |
||||||
|
createPath, |
||||||
|
createUse, |
||||||
|
createClipPath, |
||||||
|
createDefs, |
||||||
|
createForeignObject, |
||||||
|
}; |
||||||
|
|
||||||
|
const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; |
||||||
|
|
||||||
|
interface SVGOptions { |
||||||
|
viewBox?: string; |
||||||
|
height?: number; |
||||||
|
width?: number; |
||||||
|
children?: SVGElement[]; |
||||||
|
} |
||||||
|
|
||||||
|
function createSvg(options?: SVGOptions): SVGSVGElement { |
||||||
|
const svg = document.createElementNS(SVG_NAMESPACE, "svg"); |
||||||
|
if (!options) return svg; |
||||||
|
|
||||||
|
if (options.children) { |
||||||
|
options.children.forEach((child) => svg.appendChild(child)); |
||||||
|
} |
||||||
|
|
||||||
|
if (options.viewBox) { |
||||||
|
svg.setAttribute("viewBox", options.viewBox); |
||||||
|
} |
||||||
|
|
||||||
|
if (options.height) { |
||||||
|
svg.setAttribute("height", options.height.toString()); |
||||||
|
} |
||||||
|
|
||||||
|
if (options.width) { |
||||||
|
svg.setAttribute("width", options.width.toString()); |
||||||
|
} |
||||||
|
|
||||||
|
return svg; |
||||||
|
} |
||||||
|
|
||||||
|
interface GOptions { |
||||||
|
children: Array<SVGElement | null>; |
||||||
|
translate?: { |
||||||
|
x: number; |
||||||
|
y: number; |
||||||
|
}; |
||||||
|
fill?: string; |
||||||
|
stroke?: string; |
||||||
|
strokeWidth?: number; |
||||||
|
onClick?: () => void; |
||||||
|
onMouseOver?: () => void; |
||||||
|
onMouseOut?: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
function createG(options: GOptions): SVGGElement { |
||||||
|
const g = document.createElementNS(SVG_NAMESPACE, "g"); |
||||||
|
options.children.forEach((child) => child && g.appendChild(child)); |
||||||
|
|
||||||
|
if (options.translate) { |
||||||
|
g.setAttribute( |
||||||
|
"transform", |
||||||
|
`translate(${options.translate.x}, ${options.translate.y})`, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
if (options.fill) { |
||||||
|
g.setAttribute("fill", options.fill); |
||||||
|
} |
||||||
|
|
||||||
|
if (options.stroke) { |
||||||
|
g.setAttribute("stroke", options.stroke); |
||||||
|
} |
||||||
|
|
||||||
|
if (options.strokeWidth) { |
||||||
|
g.setAttribute("stroke-width", options.strokeWidth.toString()); |
||||||
|
} |
||||||
|
|
||||||
|
if (options.onClick) { |
||||||
|
g.addEventListener("click", options.onClick); |
||||||
|
} |
||||||
|
|
||||||
|
if (options.onMouseOver) { |
||||||
|
g.addEventListener("mouseover", options.onMouseOver); |
||||||
|
} |
||||||
|
|
||||||
|
if (options.onMouseOut) { |
||||||
|
g.addEventListener("mouseout", options.onMouseOut); |
||||||
|
} |
||||||
|
|
||||||
|
return g; |
||||||
|
} |
||||||
|
|
||||||
|
interface TextOptions { |
||||||
|
content: string; |
||||||
|
fill?: string; |
||||||
|
font?: string; |
||||||
|
anchor?: "start" | "middle" | "end"; |
||||||
|
translate?: { |
||||||
|
x: number; |
||||||
|
y: number; |
||||||
|
}; |
||||||
|
onClick?: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
function createText(options: TextOptions): SVGTextElement { |
||||||
|
const text = document.createElementNS(SVG_NAMESPACE, "text"); |
||||||
|
text.setAttribute("alignment-baseline", "central"); |
||||||
|
text.setAttribute("dominant-baseline", "central"); |
||||||
|
text.textContent = options.content; |
||||||
|
|
||||||
|
if (options.fill) { |
||||||
|
text.setAttribute("fill", options.fill); |
||||||
|
} |
||||||
|
|
||||||
|
if (options.font) { |
||||||
|
text.setAttribute("style", `font: ${options.font}`); |
||||||
|
} |
||||||
|
|
||||||
|
if (options.anchor) { |
||||||
|
text.setAttribute("text-anchor", options.anchor); |
||||||
|
} |
||||||
|
|
||||||
|
if (options.translate) { |
||||||
|
text.setAttribute("x", options.translate.x.toString()); |
||||||
|
text.setAttribute("y", options.translate.y.toString()); |
||||||
|
} |
||||||
|
|
||||||
|
if (options.onClick) { |
||||||
|
text.addEventListener("click", options.onClick); |
||||||
|
} |
||||||
|
|
||||||
|
return text; |
||||||
|
} |
||||||
|
|
||||||
|
interface CircleOptions { |
||||||
|
radius: number; |
||||||
|
id?: string; |
||||||
|
fill?: string; |
||||||
|
} |
||||||
|
|
||||||
|
function createCircle(options: CircleOptions): SVGCircleElement { |
||||||
|
const circle = document.createElementNS(SVG_NAMESPACE, "circle"); |
||||||
|
circle.setAttribute("cx", options.radius.toString()); |
||||||
|
circle.setAttribute("cy", options.radius.toString()); |
||||||
|
circle.setAttribute("r", options.radius.toString()); |
||||||
|
|
||||||
|
if (options.id) { |
||||||
|
circle.setAttribute("id", options.id); |
||||||
|
} |
||||||
|
|
||||||
|
if (options.fill) { |
||||||
|
circle.setAttribute("fill", options.fill); |
||||||
|
} |
||||||
|
|
||||||
|
return circle; |
||||||
|
} |
||||||
|
|
||||||
|
interface RectOptions { |
||||||
|
width: number; |
||||||
|
height: number; |
||||||
|
borderRadius?: number; |
||||||
|
fill?: string; |
||||||
|
stroke?: string; |
||||||
|
} |
||||||
|
|
||||||
|
function createRect(options: RectOptions): SVGRectElement { |
||||||
|
const rect = document.createElementNS(SVG_NAMESPACE, "rect"); |
||||||
|
rect.setAttribute("width", options.width.toString()); |
||||||
|
rect.setAttribute("height", options.height.toString()); |
||||||
|
|
||||||
|
if (options.borderRadius) { |
||||||
|
rect.setAttribute("rx", options.borderRadius.toString()); |
||||||
|
} |
||||||
|
|
||||||
|
if (options.fill) { |
||||||
|
rect.setAttribute("fill", options.fill || "none"); |
||||||
|
} |
||||||
|
|
||||||
|
if (options.stroke) { |
||||||
|
rect.setAttribute("stroke", options.stroke); |
||||||
|
} |
||||||
|
|
||||||
|
return rect; |
||||||
|
} |
||||||
|
|
||||||
|
interface PathOptions { |
||||||
|
d: string; |
||||||
|
fill?: string; |
||||||
|
stroke?: string; |
||||||
|
strokeWidth?: number; |
||||||
|
translate?: { |
||||||
|
x: number; |
||||||
|
y: number; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function createPath(options: PathOptions): SVGPathElement { |
||||||
|
const path = document.createElementNS(SVG_NAMESPACE, "path"); |
||||||
|
path.setAttribute("d", options.d); |
||||||
|
|
||||||
|
if (options.fill) { |
||||||
|
path.setAttribute("fill", options.fill); |
||||||
|
} |
||||||
|
|
||||||
|
if (options.stroke) { |
||||||
|
path.setAttribute("stroke", options.stroke); |
||||||
|
} |
||||||
|
|
||||||
|
if (options.strokeWidth) { |
||||||
|
path.setAttribute("stroke-width", options.strokeWidth.toString()); |
||||||
|
} |
||||||
|
|
||||||
|
if (options.translate) { |
||||||
|
path.setAttribute( |
||||||
|
"transform", |
||||||
|
`translate(${options.translate.x}, ${options.translate.y})`, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return path; |
||||||
|
} |
||||||
|
|
||||||
|
function createUse(href: string): SVGUseElement { |
||||||
|
const use = document.createElementNS(SVG_NAMESPACE, "use"); |
||||||
|
use.setAttribute("href", `#${href}`); |
||||||
|
// xlink:href is deprecated in SVG2, but we keep it for retro-compatibility
|
||||||
|
// => https://developer.mozilla.org/en-US/docs/Web/SVG/Element/use#Browser_compatibility
|
||||||
|
use.setAttributeNS("http://www.w3.org/1999/xlink", "xlink:href", `#${href}`); |
||||||
|
|
||||||
|
return use; |
||||||
|
} |
||||||
|
|
||||||
|
function createClipPath(): SVGClipPathElement { |
||||||
|
return document.createElementNS(SVG_NAMESPACE, "clipPath"); |
||||||
|
} |
||||||
|
|
||||||
|
function createDefs(children: SVGElement[]): SVGDefsElement { |
||||||
|
const defs = document.createElementNS(SVG_NAMESPACE, "defs"); |
||||||
|
children.forEach((child) => defs.appendChild(child)); |
||||||
|
|
||||||
|
return defs; |
||||||
|
} |
||||||
|
|
||||||
|
interface ForeignObjectOptions { |
||||||
|
content: string; |
||||||
|
width: number; |
||||||
|
translate?: { |
||||||
|
x: number; |
||||||
|
y: number; |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function createForeignObject( |
||||||
|
options: ForeignObjectOptions, |
||||||
|
): SVGForeignObjectElement { |
||||||
|
const result = document.createElementNS(SVG_NAMESPACE, "foreignObject"); |
||||||
|
result.setAttribute("width", options.width.toString()); |
||||||
|
|
||||||
|
if (options.translate) { |
||||||
|
result.setAttribute("x", options.translate.x.toString()); |
||||||
|
result.setAttribute("y", options.translate.y.toString()); |
||||||
|
} |
||||||
|
|
||||||
|
const p = document.createElement("p"); |
||||||
|
p.textContent = options.content; |
||||||
|
result.appendChild(p); |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
@ -0,0 +1,60 @@ |
|||||||
|
import type { Commit } from "../gitgraph-core"; |
||||||
|
|
||||||
|
import { createG, createPath, createText } from "./svg-elements"; |
||||||
|
|
||||||
|
export { createTooltip, PADDING }; |
||||||
|
|
||||||
|
const PADDING = 10; |
||||||
|
const OFFSET = 10; |
||||||
|
|
||||||
|
function createTooltip(commit: Commit): SVGElement { |
||||||
|
const path = createPath({ d: "", fill: "#EEE" }); |
||||||
|
const text = createText({ |
||||||
|
translate: { x: OFFSET + PADDING, y: 0 }, |
||||||
|
content: `${commit.hashAbbrev} - ${commit.subject}`, |
||||||
|
fill: "#333", |
||||||
|
}); |
||||||
|
|
||||||
|
const commitSize = commit.style.dot.size * 2; |
||||||
|
const tooltip = createG({ |
||||||
|
translate: { x: commitSize, y: commitSize / 2 }, |
||||||
|
children: [path], |
||||||
|
}); |
||||||
|
|
||||||
|
const observer = new MutationObserver(() => { |
||||||
|
const { width } = text.getBBox(); |
||||||
|
|
||||||
|
const radius = 5; |
||||||
|
const boxHeight = 50; |
||||||
|
const boxWidth = OFFSET + width + 2 * PADDING; |
||||||
|
|
||||||
|
const pathD = [ |
||||||
|
"M 0,0", |
||||||
|
`L ${OFFSET},${OFFSET}`, |
||||||
|
`V ${boxHeight / 2 - radius}`, |
||||||
|
`Q ${OFFSET},${boxHeight / 2} ${OFFSET + radius},${boxHeight / 2}`, |
||||||
|
`H ${boxWidth - radius}`, |
||||||
|
`Q ${boxWidth},${boxHeight / 2} ${boxWidth},${boxHeight / 2 - radius}`, |
||||||
|
`V -${boxHeight / 2 - radius}`, |
||||||
|
`Q ${boxWidth},-${boxHeight / 2} ${boxWidth - radius},-${boxHeight / 2}`, |
||||||
|
`H ${OFFSET + radius}`, |
||||||
|
`Q ${OFFSET},-${boxHeight / 2} ${OFFSET},-${boxHeight / 2 - radius}`, |
||||||
|
`V -${OFFSET}`, |
||||||
|
"z", |
||||||
|
].join(" "); |
||||||
|
|
||||||
|
// Ideally, it would be great to refactor these behavior into SVG elements.
|
||||||
|
// rect.setAttribute("width", boxWidth.toString());
|
||||||
|
path.setAttribute("d", pathD.toString()); |
||||||
|
}); |
||||||
|
|
||||||
|
observer.observe(tooltip, { |
||||||
|
attributes: false, |
||||||
|
subtree: false, |
||||||
|
childList: true, |
||||||
|
}); |
||||||
|
|
||||||
|
tooltip.appendChild(text); |
||||||
|
|
||||||
|
return tooltip; |
||||||
|
} |
Loading…
Reference in new issue