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