branch DAG history visualization

pull/19/head
Niko PLP 5 months ago
parent 4dbf3aa648
commit 577b0b5c24
  1. 2
      ng-app/package.json
  2. 21
      ng-app/src/history/LICENSE.md
  3. 74
      ng-app/src/history/gitgraph-core/branch.ts
  4. 342
      ng-app/src/history/gitgraph-core/branches-paths.ts
  5. 275
      ng-app/src/history/gitgraph-core/commit.ts
  6. 229
      ng-app/src/history/gitgraph-core/gitgraph-user-api.ts
  7. 375
      ng-app/src/history/gitgraph-core/gitgraph.ts
  8. 11
      ng-app/src/history/gitgraph-core/graph-rows.ts
  9. 11
      ng-app/src/history/gitgraph-core/index.ts
  10. 46
      ng-app/src/history/gitgraph-core/regular-graph-rows.ts
  11. 464
      ng-app/src/history/gitgraph-core/template.ts
  12. 200
      ng-app/src/history/gitgraph-core/utils.ts
  13. 489
      ng-app/src/history/gitgraph-js/gitgraph.ts
  14. 274
      ng-app/src/history/gitgraph-js/svg-elements.ts
  15. 60
      ng-app/src/history/gitgraph-js/tooltip.ts
  16. 520
      ng-app/src/lib/Test.svelte
  17. 9
      ng-app/src/store.ts
  18. 30
      ng-net/src/app_protocol.rs
  19. 11
      ng-repo/src/commit.rs
  20. 196
      ng-repo/src/repo.rs
  21. 19
      ng-repo/src/types.rs
  22. 5
      ng-sdk-js/app-node/index.js
  23. 6
      ng-sdk-js/src/lib.rs
  24. 2
      ng-verifier/src/commits/mod.rs
  25. 16
      ng-verifier/src/request_processor.rs
  26. 2
      pnpm-lock.yaml

@ -23,7 +23,7 @@
"classnames": "^2.3.2",
"flowbite": "^1.6.5",
"flowbite-svelte": "^0.43.3",
"ng-sdk-js": "workspace:^0.1.0",
"ng-sdk-js": "workspace:^0.1.0-preview.1",
"svelte-spa-router": "^3.3.0",
"vite-plugin-top-level-await": "^1.3.1"
},

@ -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,200 @@
import type { Commit } from "./commit";
import type { GitgraphCore } from "./gitgraph";
import type { Coordinate } from "./branches-paths";
export {
type Omit,
type NonMatchingPropNames,
type NonMatchingProp,
booleanOptionOr,
numberOptionOr,
pick,
debug,
isUndefined,
withoutUndefinedKeys,
arrowSvgPath,
};
/**
* Omit some keys from an original type.
*/
type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
/**
* Get all property names not matching a type.
*
* @ref http://tycho01.github.io/typical/modules/_object_nonmatchingpropsnames_.html
*/
type NonMatchingPropNames<T, X> = {
[K in keyof T]: T[K] extends X ? never : K;
}[keyof T];
/**
* Get all properties with names not matching a type.
*
* @ref http://tycho01.github.io/typical/modules/_object_nonmatchingprops_.html
*/
type NonMatchingProp<T, X> = Pick<T, NonMatchingPropNames<T, X>>;
/**
* Provide a default value to a boolean.
* @param value
* @param defaultValue
*/
function booleanOptionOr(value: any, defaultValue: boolean): boolean {
return typeof value === "boolean" ? value : defaultValue;
}
/**
* Provide a default value to a number.
* @param value
* @param defaultValue
*/
function numberOptionOr(value: any, defaultValue: number): number {
return typeof value === "number" ? value : defaultValue;
}
/**
* Creates an object composed of the picked object properties.
* @param obj The source object
* @param paths The property paths to pick
*/
function pick<T, K extends keyof T>(obj: T, paths: K[]): Pick<T, K> {
return {
...paths.reduce((mem, key) => ({ ...mem, [key]: obj[key] }), {}),
} as Pick<T, K>;
}
/**
* Print a light version of commits into the console.
* @param commits List of commits
* @param paths The property paths to pick
*/
function debug<TNode = SVGElement>(
commits: Array<Commit<TNode>>,
paths: Array<keyof Commit<TNode>>,
): void {
// tslint:disable-next-line:no-console
console.log(
JSON.stringify(
commits.map((commit) => pick(commit, paths)),
null,
2,
),
);
}
/**
* Return true if is undefined.
*
* @param obj
*/
function isUndefined(obj: any): obj is undefined {
return obj === undefined;
}
/**
* Return a version of the object without any undefined keys.
*
* @param obj
*/
function withoutUndefinedKeys<T>(
obj: T = {} as T,
): NonMatchingProp<T, undefined> {
return (Object.keys(obj) as [keyof T]).reduce<T>(
(mem: any, key) =>
isUndefined(obj[key]) ? mem : { ...mem, [key]: obj[key] },
{} as T,
);
}
/**
* Return a string ready to use in `svg.path.d` to draw an arrow from params.
*
* @param graph Graph context
* @param parent Parent commit of the target commit
* @param commit Target commit
*/
function arrowSvgPath<TNode = SVGElement>(
graph: GitgraphCore<TNode>,
parent: Coordinate,
commit: Commit<TNode>,
): string {
const commitRadius = commit.style.dot.size;
const size = graph.template.arrow.size!;
const h = commitRadius + graph.template.arrow.offset;
// Delta between left & right (radian)
const delta = Math.PI / 7;
// Alpha angle between parent & commit (radian)
const alpha = getAlpha(graph, parent, commit);
// Top
const x1 = h * Math.cos(alpha);
const y1 = h * Math.sin(alpha);
// Bottom right
const x2 = (h + size) * Math.cos(alpha - delta);
const y2 = (h + size) * Math.sin(alpha - delta);
// Bottom center
const x3 = (h + size / 2) * Math.cos(alpha);
const y3 = (h + size / 2) * Math.sin(alpha);
// Bottom left
const x4 = (h + size) * Math.cos(alpha + delta);
const y4 = (h + size) * Math.sin(alpha + delta);
return `M${x1},${y1} L${x2},${y2} Q${x3},${y3} ${x4},${y4} L${x4},${y4}`;
}
function getAlpha<TNode = SVGElement>(
graph: GitgraphCore<TNode>,
parent: Coordinate,
commit: Commit<TNode>,
): number {
const deltaX = parent.x - commit.x;
const deltaY = parent.y - commit.y;
const commitSpacing = graph.template.commit.spacing;
let alphaY;
let alphaX;
// Angle usually start from previous commit Y position:
//
// o
// ↑ ↖
// o | <-- path is straight until last commit Y position
// ↑ o
// | ↗
// o
//
// So we can to default to commit spacing.
// For horizontal orientation => same with commit X position.
if (graph.isReverse) {
alphaY = commitSpacing;
alphaX = deltaX;
} else {
alphaY = -commitSpacing;
alphaX = deltaX;
}
// If commit is distant from its parent, there should be no angle.
//
// o
// ↑ <-- arrow is like previous commit was on same X position
// o |
// | /
// o
//
// For horizontal orientation => same with commit Y position.
//if (graph.isVertical) {
if (Math.abs(deltaY) > commitSpacing) alphaX = 0;
// } else {
// if (Math.abs(deltaX) > commitSpacing) alphaY = 0;
// }
return Math.atan2(alphaY, alphaX);
}

@ -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;
}

@ -10,6 +10,11 @@
-->
<script lang="ts">
import {
createGitgraph,
templateExtend,
TemplateName,
} from "../history/gitgraph-js/gitgraph";
import ng from "../api";
import {
branch_subs,
@ -18,7 +23,7 @@
online,
} from "../store";
import { link } from "svelte-spa-router";
import { onMount, onDestroy } from "svelte";
import { onMount, onDestroy, tick } from "svelte";
import { Button } from "flowbite-svelte";
let is_tauri = import.meta.env.TAURI_PLATFORM;
@ -27,7 +32,498 @@
let img_map = {};
onMount(() => {});
let gitgraph;
let next = [
{
hash: "I",
subject: "niko2",
author: "",
parents: ["G"],
},
{
hash: "T",
subject: "niko2",
author: "",
parents: ["D", "H"],
},
{
hash: "Z",
subject: "niko2",
author: "",
parents: ["E"],
},
{
hash: "L",
subject: "niko2",
author: "",
parents: ["H"],
},
{
hash: "J",
subject: "niko2",
author: "",
parents: ["L", "Z", "I"],
},
{
hash: "K",
subject: "niko2",
author: "",
parents: ["G", "E"],
},
{
hash: "X",
subject: "niko2",
author: "",
parents: ["I"],
},
{
hash: "Q",
subject: "niko2",
author: "",
parents: ["L", "X"],
},
];
function add() {
let n = next.shift();
if (n) gitgraph.commit(n);
}
onMount(async () => {
const graphContainer = document.getElementById("graph-container");
gitgraph = createGitgraph(graphContainer, {
template: templateExtend(TemplateName.Metro, {
branch: { label: { display: false } },
commit: { message: { displayAuthor: false } },
}),
});
gitgraph.swimlanes(["A", "F", "C"]);
gitgraph.import([
{
hash: "A",
subject: "niko2",
branch: "A",
parents: [],
author: "",
x: 0,
y: 0,
},
{
hash: "B",
subject: "niko2",
branch: "A",
author: "",
parents: ["A"],
x: 0,
y: 1,
},
{
hash: "D",
subject: "niko2",
branch: "A",
author: "",
parents: ["B"],
x: 0,
y: 2,
},
{
hash: "C",
subject: "niko2",
branch: "C",
author: "",
parents: ["A"],
x: 2,
y: 3,
},
{
hash: "F",
subject: "niko2",
branch: "F",
author: "",
parents: ["B", "C"],
x: 1,
y: 4,
},
{
hash: "G",
subject: "niko2",
branch: "F",
parents: ["F"],
author: "",
x: 1,
y: 5,
},
{
hash: "E",
subject: "niko2",
branch: "C",
author: "",
parents: ["C"],
x: 2,
y: 6,
},
// {
// hash: "H",
// subject: "niko2",
// branch: "A",
// author: "",
// parents: ["D", "G"],
// x: 0,
// y: 7,
// },
// {
// hash: "I",
// subject: "niko2",
// branch: "A",
// author: "",
// parents: ["D", "H"],
// x: 0,
// y: 8,
// },
// {
// hash: "H",
// subject: "niko2",
// branch: "A",
// author: "",
// parents: ["D", "G", "E"],
// x: 0,
// y: 7,
// },
]);
// window.setTimeout(() => {
// gitgraph.commit({
// hash: "H",
// subject: "niko2",
// author: "",
// parents: ["D", "G", "E"],
// });
// }, 0);
window.setTimeout(() => {
gitgraph.commit({
hash: "H",
subject: "niko2",
author: "",
parents: ["G", "E"],
});
}, 0);
// window.setTimeout(() => {
// gitgraph.commit({
// hash: "H",
// subject: "niko2",
// author: "",
// parents: ["G"],
// });
// }, 0);
// gitgraph.swimlanes(["A", "B", false, "D"]);
// gitgraph.import([
// {
// hash: "A",
// subject: "niko2",
// branch: "A",
// parents: [],
// author: "",
// x: 0,
// y: 0,
// },
// {
// hash: "C",
// subject: "niko2",
// branch: "A",
// author: "",
// parents: ["A"],
// x: 0,
// y: 1,
// },
// {
// hash: "D",
// subject: "niko2",
// branch: "A",
// author: "",
// parents: ["C"],
// x: 0,
// y: 2,
// },
// {
// hash: "E",
// subject: "niko2",
// branch: "A",
// author: "",
// parents: ["D"],
// x: 0,
// y: 3,
// },
// {
// hash: "B",
// subject: "niko2",
// branch: "C",
// author: "",
// parents: ["A"],
// x: 2,
// y: 4,
// },
// {
// hash: "G",
// subject: "niko2",
// branch: "C",
// parents: ["B"],
// author: "",
// x: 2,
// y: 5,
// },
// {
// hash: "F",
// subject: "niko2",
// branch: "B",
// author: "",
// parents: ["D", "G"],
// x: 1,
// y: 6,
// },
// {
// hash: "H",
// subject: "niko2",
// branch: "D",
// author: "",
// parents: ["G"],
// x: 3,
// y: 7,
// },
// // {
// // hash: "I",
// // subject: "niko2",
// // branch: "A",
// // author: "",
// // parents: ["E", "F", "H"],
// // x: 0,
// // y: 8,
// // },
// ]);
// gitgraph.swimlanes([
// "A",
// false,
// false,
// false,
// false,
// false,
// false,
// false,
// false,
// false,
// false,
// false,
// false,
// ]);
// gitgraph.import([
// {
// hash: "A",
// subject: "niko2",
// branch: "A",
// parents: [],
// author: "",
// x: 0,
// y: 0,
// },
// {
// hash: "B",
// subject: "niko2",
// branch: "A",
// author: "",
// parents: ["A"],
// x: 0,
// y: 1,
// },
// {
// hash: "C",
// subject: "niko2",
// branch: "A",
// author: "",
// parents: ["B"],
// x: 0,
// y: 2,
// },
// {
// hash: "D",
// subject: "niko2",
// branch: "A",
// author: "",
// parents: ["C"],
// x: 0,
// y: 3,
// },
// {
// hash: "E",
// subject: "niko2",
// branch: "A",
// author: "",
// parents: ["D"],
// x: 0,
// y: 4,
// },
// {
// hash: "J",
// subject: "niko2",
// branch: "J",
// parents: ["A"],
// author: "",
// x: 2,
// y: 5,
// },
// {
// hash: "K",
// subject: "niko2",
// branch: "J",
// author: "",
// parents: ["J"],
// x: 2,
// y: 6,
// },
// {
// hash: "L",
// subject: "niko2",
// branch: "L",
// author: "",
// parents: ["A"],
// x: 3,
// y: 7,
// },
// {
// hash: "M",
// subject: "niko2",
// branch: "L",
// author: "",
// parents: ["L"],
// x: 3,
// y: 8,
// },
// {
// hash: "G",
// subject: "niko2",
// branch: "G",
// author: "",
// parents: ["C", "K", "M"],
// x: 1,
// y: 9,
// },
// {
// hash: "H",
// subject: "niko2",
// branch: "G",
// author: "",
// parents: ["G"],
// x: 1,
// y: 10,
// },
// {
// hash: "I",
// subject: "niko2",
// branch: "G",
// author: "",
// parents: ["H"],
// x: 1,
// y: 11,
// },
// {
// hash: "F",
// subject: "niko2",
// branch: "A",
// author: "",
// parents: ["E", "I"],
// x: 0,
// y: 12,
// },
// {
// hash: "1",
// subject: "niko2",
// branch: "1",
// author: "",
// parents: ["A"],
// x: 4,
// y: 13,
// },
// {
// hash: "2",
// subject: "niko2",
// branch: "2",
// author: "",
// parents: ["A"],
// x: 5,
// y: 14,
// },
// {
// hash: "3",
// subject: "niko2",
// branch: "3",
// author: "",
// parents: ["A"],
// x: 6,
// y: 15,
// },
// {
// hash: "4",
// subject: "niko2",
// branch: "4",
// author: "",
// parents: ["A"],
// x: 7,
// y: 16,
// },
// {
// hash: "5",
// subject: "niko2",
// branch: "5",
// author: "",
// parents: ["A"],
// x: 8,
// y: 17,
// },
// {
// hash: "6",
// subject: "niko2",
// branch: "6",
// author: "",
// parents: ["A"],
// x: 9,
// y: 18,
// },
// {
// hash: "7",
// subject: "niko2",
// branch: "7",
// author: "",
// parents: ["A"],
// x: 10,
// y: 19,
// },
// {
// hash: "8",
// subject: "niko2",
// branch: "8",
// author: "",
// parents: ["A"],
// x: 11,
// y: 20,
// },
// {
// hash: "9",
// subject: "niko2",
// branch: "9",
// author: "",
// parents: ["A"],
// x: 12,
// y: 21,
// },
// ]);
});
async function get_img(ref) {
if (!ref) return false;
@ -195,6 +691,7 @@
</script>
<div>
<div id="graph-container"></div>
{#if $cannot_load_offline}
<div class="row p-4">
<p>
@ -215,6 +712,15 @@
<!-- <a use:link href="/">
<button tabindex="-1" class=" mr-5 select-none"> Back home </button>
</a> -->
<Button
type="button"
on:click={() => {
add();
}}
class="text-white bg-primary-700 hover:bg-primary-700/90 focus:ring-4 focus:ring-primary-700/50 font-medium rounded-lg text-lg px-5 py-2.5 text-center inline-flex items-center dark:focus:ring-primary-700/55 mr-2 mb-2"
>
g
</Button>
<Button
disabled={!$online && !is_tauri}
type="button"
@ -254,15 +760,11 @@
{:then}
{#each $files as file}
<p>
{file.V0.File.name}
{file.name}
{#await get_img(file.V0.File) then url}
{#await get_img(file) then url}
{#if url}
<img
src={url}
title={"did:ng" + file.V0.File.nuri}
alt={file.V0.File.name}
/>
<img src={url} title={file.nuri} alt={file.name} />
{/if}
{/await}
</p>

@ -240,7 +240,14 @@ export const branch_subs = function(nuri) {
unsub = await ng.app_request_stream(req,
async (commit) => {
//console.log("GOT APP RESPONSE", commit);
update( (old) => {old.unshift(commit); return old;} )
if (commit.V0.State) {
for (const file of commit.V0.State.files) {
update( (old) => {old.unshift(file); return old;} )
}
} else if (commit.V0.Patch.other?.FileAdd) {
update( (old) => {old.unshift(commit.V0.Patch.other.FileAdd); return old;} )
}
});
}
catch (e) {

@ -9,18 +9,16 @@
//! App Protocol (between LocalBroker and Verifier)
use std::collections::HashMap;
use lazy_static::lazy_static;
use ng_repo::repo::CommitInfo;
use ng_repo::utils::decode_overlayid;
use regex::Regex;
use serde::{Deserialize, Serialize};
use ng_repo::errors::NgError;
#[allow(unused_imports)]
use ng_repo::log::*;
use ng_repo::repo::CommitInfo;
use ng_repo::types::*;
use ng_repo::utils::decode_overlayid;
use ng_repo::utils::{decode_digest, decode_key, decode_sym_key};
use crate::types::*;
@ -146,6 +144,9 @@ pub struct CommitInfoJs {
pub author: String,
pub final_consistency: bool,
pub commit_type: CommitType,
pub branch: String,
pub x: u32,
pub y: u32,
}
impl From<&CommitInfo> for CommitInfoJs {
@ -157,6 +158,9 @@ impl From<&CommitInfo> for CommitInfoJs {
author: info.author.clone(),
final_consistency: info.final_consistency,
commit_type: info.commit_type.clone(),
branch: info.branch.unwrap().to_string(),
x: info.x,
y: info.y,
}
}
}
@ -645,25 +649,29 @@ pub struct AppState {
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AppHistory {
pub heads: Vec<ObjectId>,
pub history: HashMap<ObjectId, CommitInfo>,
pub history: Vec<(ObjectId, CommitInfo)>,
pub swimlane_state: Vec<Option<ObjectId>>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AppHistoryJs {
pub heads: Vec<String>,
pub history: HashMap<String, CommitInfoJs>,
pub history: Vec<(String, CommitInfoJs)>,
pub swimlane_state: Vec<Option<String>>,
}
impl AppHistory {
pub fn to_js(&self) -> AppHistoryJs {
AppHistoryJs {
heads: self.heads.iter().map(|h| h.to_string()).collect(),
history: HashMap::from_iter(
history: Vec::from_iter(
self.history
.iter()
.map(|(id, info)| (id.to_string(), info.into())),
),
swimlane_state: Vec::from_iter(
self.swimlane_state
.iter()
.map(|lane| lane.map_or(None, |b| Some(b.to_string()))),
),
}
}
}
@ -673,6 +681,8 @@ pub enum OtherPatch {
FileAdd(FileName),
FileRemove(ObjectId),
AsyncSignature((ObjectRef, Vec<ObjectId>)),
Snapshot(ObjectRef),
Compact(ObjectRef),
Other,
}

@ -448,13 +448,22 @@ impl Commit {
}
pub fn as_info(&self, repo: &Repo) -> CommitInfo {
let past = self.acks_ids();
// past.sort();
// let branch = past
// .is_empty()
// .then_some(self.id().unwrap())
// .or(Some(past[0]));
CommitInfo {
past: self.acks_ids(),
past,
key: self.key().unwrap(),
signature: None,
author: repo.get_user_string(self.author()),
final_consistency: self.final_consistency(),
commit_type: self.get_type().unwrap(),
branch: None,
x: 0,
y: 0,
}
}

@ -163,6 +163,9 @@ pub struct CommitInfo {
pub author: String,
pub final_consistency: bool,
pub commit_type: CommitType,
pub branch: Option<ObjectId>,
pub x: u32,
pub y: u32,
}
impl Repo {
@ -182,13 +185,20 @@ impl Repo {
fn load_causal_past(
&self,
cobj: &Commit,
visited: &mut HashMap<ObjectId, CommitInfo>,
) -> Result<(), VerifierError> {
visited: &mut HashMap<ObjectId, (HashSet<ObjectId>, CommitInfo)>,
future: Option<ObjectId>,
) -> Result<Option<ObjectId>, VerifierError> {
let mut root = None;
let id = cobj.id().unwrap();
if visited.get(&id).is_none() {
if let Some((future_set, _)) = visited.get_mut(&id) {
// we update the future
if let Some(f) = future {
future_set.insert(f);
}
} else {
let commit_type = cobj.get_type().unwrap();
let acks = cobj.acks();
let (past, real_acks) = match commit_type {
let (past, real_acks, next_future) = match commit_type {
CommitType::SyncSignature => {
assert_eq!(acks.len(), 1);
let dep = cobj.deps();
@ -196,6 +206,7 @@ impl Repo {
let mut current_commit = dep[0].clone();
let sign_ref = cobj.get_signature_reference().unwrap();
let real_acks;
let mut future = id;
loop {
let o = Commit::load(current_commit.clone(), &self.store, true)?;
let deps = o.deps();
@ -206,9 +217,14 @@ impl Repo {
author: self.get_user_string(o.author()),
final_consistency: o.final_consistency(),
commit_type: o.get_type().unwrap(),
branch: None,
x: 0,
y: 0,
};
let id = o.id().unwrap();
visited.insert(id, commit_info);
visited.insert(id, ([future].into(), commit_info));
future = id;
if id == acks[0].id {
real_acks = o.acks();
break;
@ -216,17 +232,17 @@ impl Repo {
assert_eq!(deps.len(), 1);
current_commit = deps[0].clone();
}
(vec![dep[0].id], real_acks)
(vec![dep[0].id], real_acks, future)
}
CommitType::AsyncSignature => {
let past: Vec<ObjectId> = acks.iter().map(|r| r.id.clone()).collect();
for p in past.iter() {
visited.get_mut(p).unwrap().signature =
visited.get_mut(p).unwrap().1.signature =
Some(cobj.get_signature_reference().unwrap());
}
(past, acks)
(past, acks, id)
}
_ => (acks.iter().map(|r| r.id.clone()).collect(), acks),
_ => (acks.iter().map(|r| r.id.clone()).collect(), acks, id),
};
let commit_info = CommitInfo {
@ -236,27 +252,175 @@ impl Repo {
author: self.get_user_string(cobj.author()),
final_consistency: cobj.final_consistency(),
commit_type,
branch: None,
x: 0,
y: 0,
};
visited.insert(id, commit_info);
visited.insert(id, (future.map_or([].into(), |f| [f].into()), commit_info));
if real_acks.is_empty() {
root = Some(next_future);
}
for past_ref in real_acks {
let o = Commit::load(past_ref, &self.store, true)?;
self.load_causal_past(&o, visited)?;
if let Some(r) = self.load_causal_past(&o, visited, Some(next_future))? {
root = Some(r);
}
}
}
Ok(())
Ok(root)
}
fn past_is_all_in(past: &Vec<ObjectId>, already_in: &HashMap<ObjectId, ObjectId>) -> bool {
for p in past {
if !already_in.contains_key(p) {
return false;
}
}
true
}
fn collapse(
id: &ObjectId,
dag: &mut HashMap<ObjectId, (HashSet<ObjectId>, CommitInfo)>,
already_in: &mut HashMap<ObjectId, ObjectId>,
branches_order: &mut Vec<Option<ObjectId>>,
branches: &mut HashMap<ObjectId, usize>,
//swimlanes: &mut Vec<Vec<ObjectId>>,
) -> Vec<(ObjectId, CommitInfo)> {
let (_, c) = dag.get(id).unwrap();
log_debug!("{id}");
if c.past.len() > 1 && !Self::past_is_all_in(&c.past, already_in) {
// we postpone the merge until all the past commits have been added
// log_debug!("postponed {}", id);
vec![]
} else {
let (future, mut info) = dag.remove(id).unwrap();
let mut branch = match info.past.len() {
0 => *id,
_ => info.branch.unwrap(),
// _ => {
// we merge on the smallest branch ordinal.
// let smallest_branch = info
// .past
// .iter()
// .map(|past_commit| {
// branches.get(already_in.get(past_commit).unwrap()).unwrap()
// })
// .min()
// .unwrap();
// branches_order
// .get(*smallest_branch)
// .unwrap()
// .unwrap()
// .clone()
// }
};
info.branch = Some(branch.clone());
// let swimlane_idx = branches.get(&branch).unwrap();
// let swimlane = swimlanes.get_mut(*swimlane_idx).unwrap();
// if swimlane.last().map_or(true, |last| last != &branch) {
// swimlane.push(branch.clone());
// }
let mut res = vec![(*id, info)];
let first_child_branch = branch.clone();
already_in.insert(*id, branch);
let mut future = Vec::from_iter(future);
future.sort();
// the first branch is the continuation as parent.
let mut iterator = future.iter().peekable();
while let Some(child) = iterator.next() {
//log_debug!("child of {} : {}", id, child);
{
// we merge on the smallest branch ordinal.
let (_, info) = dag.get_mut(child).unwrap();
if let Some(b) = info.branch.to_owned() {
let previous_ordinal = branches.get(&b).unwrap();
let new_ordinal = branches.get(&branch).unwrap();
let close = if previous_ordinal > new_ordinal {
let _ = info.branch.insert(branch);
// we close the previous branch
&b
} else {
// otherwise we close the new branch
&branch
};
let i = branches.get(close).unwrap();
branches_order.get_mut(*i).unwrap().take();
} else {
let _ = info.branch.insert(branch);
}
}
res.append(&mut Self::collapse(
child,
dag,
already_in,
branches_order,
branches,
//swimlanes,
));
// each other child gets a new branch
if let Some(next) = iterator.peek() {
branch = **next;
let mut branch_inserted = false;
let mut first_child_branch_passed = false;
for (i, next_branch) in branches_order.iter_mut().enumerate() {
if let Some(b) = next_branch {
if b == &first_child_branch {
first_child_branch_passed = true;
}
}
if next_branch.is_none() && first_child_branch_passed {
let _ = next_branch.insert(branch.clone());
branches.insert(branch, i);
branch_inserted = true;
break;
}
}
if !branch_inserted {
//swimlanes.push(Vec::new());
branches_order.push(Some(branch.clone()));
branches.insert(branch, branches_order.len() - 1);
}
}
}
res
}
}
pub fn history_at_heads(
&self,
heads: &[ObjectRef],
) -> Result<HashMap<ObjectId, CommitInfo>, VerifierError> {
let mut res = HashMap::new();
) -> Result<(Vec<(ObjectId, CommitInfo)>, Vec<Option<ObjectId>>), VerifierError> {
assert!(!heads.is_empty());
let mut visited = HashMap::new();
let mut root = None;
for id in heads {
if let Ok(cobj) = Commit::load(id.clone(), &self.store, true) {
self.load_causal_past(&cobj, &mut res)?;
root = self.load_causal_past(&cobj, &mut visited, None)?;
}
}
Ok(res)
if root.is_none() {
return Err(VerifierError::MalformedDag);
}
let root = root.unwrap();
let mut already_in: HashMap<ObjectId, ObjectId> = HashMap::new();
let mut branches_order: Vec<Option<ObjectId>> = vec![Some(root.clone())];
let mut branches: HashMap<ObjectId, usize> = HashMap::from([(root.clone(), 0)]);
//let mut swimlanes: Vec<Vec<ObjectId>> = vec![vec![root.clone()]];
let mut commits = Self::collapse(
&root,
&mut visited,
&mut already_in,
&mut branches_order,
&mut branches,
//&mut swimlanes,
);
for (i, (_, commit)) in commits.iter_mut().enumerate() {
commit.y = i as u32;
commit.x = *branches.get(commit.branch.as_ref().unwrap()).unwrap() as u32;
}
Ok((commits, branches_order))
}
pub fn update_branch_current_heads(

@ -12,6 +12,7 @@
//! Corresponds to the BARE schema
use core::fmt;
use std::cmp::Ordering;
use std::hash::Hash;
use once_cell::sync::OnceCell;
@ -39,6 +40,22 @@ pub enum Digest {
Blake3Digest32(Blake3Digest32),
}
impl Ord for Digest {
fn cmp(&self, other: &Self) -> Ordering {
match self {
Self::Blake3Digest32(left) => match other {
Self::Blake3Digest32(right) => left.cmp(right),
},
}
}
}
impl PartialOrd for Digest {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Digest {
pub fn from_slice(slice: [u8; 32]) -> Digest {
Digest::Blake3Digest32(slice)
@ -1945,7 +1962,7 @@ pub enum Snapshot {
///
/// hard snapshot will erase all the CommitBody of ancestors in the branch
/// the compact boolean should be set in the Header too.
/// after a hard snapshot, it is recommended to refresh the read capability (to empty the topics of they keys they still hold)
/// after a hard snapshot, it is recommended to refresh the read capability (to empty the topics of the keys they still hold)
/// If a branch is based on a hard snapshot, it cannot be merged back into the branch where the hard snapshot was made.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct CompactV0 {

@ -40,7 +40,10 @@ ng.init_headless(config).then( async() => {
}
let history = await ng.branch_history(session.session_id);
console.log(history);
for (const h of history.history) {
console.log(h[0], h[1]);
}
console.log(history.swimlane_state);
// await ng.sparql_update(session.session_id, "DELETE DATA { <did:ng:t:AJQ5gCLoXXjalC9diTDCvxxWu5ZQUcYWEE821nhVRMcE> <did:ng:i> <did:ng:j> }");

@ -287,7 +287,6 @@ pub async fn rdf_dump(session_id: JsValue) -> Result<String, String> {
}
}
#[cfg(wasmpack_target = "nodejs")]
#[wasm_bindgen]
pub async fn branch_history(session_id: JsValue) -> Result<JsValue, String> {
let session_id: u64 = serde_wasm_bindgen::from_value::<u64>(session_id)
@ -306,10 +305,7 @@ pub async fn branch_history(session_id: JsValue) -> Result<JsValue, String> {
let AppResponse::V0(res) = res;
match res {
AppResponseV0::History(s) => Ok(s
.to_js()
.serialize(&serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true))
.unwrap()),
AppResponseV0::History(s) => Ok(serde_wasm_bindgen::to_value(&s.to_js()).unwrap()),
_ => Err("invalid response".to_string()),
}
}

@ -453,7 +453,7 @@ impl CommitVerifier for AddFile {
let refe = commit.files().remove(0);
let filename = FileName {
name: self.name().clone(),
nuri: refe.nuri(),
nuri: NuriV0::object_ref(&refe),
reference: refe,
};
let commit_id = commit.id().unwrap();

@ -229,16 +229,11 @@ impl Verifier {
fn history_for_nuri(
&self,
target: &NuriTargetV0,
) -> Result<(Vec<ObjectId>, HashMap<ObjectId, CommitInfo>), VerifierError> {
) -> Result<(Vec<(ObjectId, CommitInfo)>, Vec<Option<ObjectId>>), VerifierError> {
let (repo_id, branch_id, store_repo) = self.resolve_target(target)?; // TODO deal with targets that are commit heads
let repo = self.get_repo(&repo_id, &store_repo)?;
let branch = repo.branch(&branch_id)?;
repo.history_at_heads(&branch.current_heads).map(|history| {
(
branch.current_heads.iter().map(|h| h.id.clone()).collect(),
history,
)
})
repo.history_at_heads(&branch.current_heads)
}
pub(crate) async fn process(
@ -318,9 +313,10 @@ impl Verifier {
return Ok(match self.history_for_nuri(&nuri.target) {
Err(e) => AppResponse::error(e.to_string()),
Ok((heads, history)) => {
AppResponse::V0(AppResponseV0::History(AppHistory { heads, history }))
}
Ok(history) => AppResponse::V0(AppResponseV0::History(AppHistory {
history: history.0,
swimlane_state: history.1,
})),
});
}
_ => unimplemented!(),

@ -22,7 +22,7 @@ importers:
flowbite: ^1.6.5
flowbite-svelte: ^0.43.3
internal-ip: ^7.0.0
ng-sdk-js: workspace:^0.1.0
ng-sdk-js: workspace:^0.1.0-preview.1
node-gzip: ^1.1.2
postcss: ^8.4.23
postcss-load-config: ^4.0.1

Loading…
Cancel
Save