diff --git a/ng-app/package.json b/ng-app/package.json index 25fb668..20b53f6 100644 --- a/ng-app/package.json +++ b/ng-app/package.json @@ -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" }, diff --git a/ng-app/src/history/LICENSE.md b/ng-app/src/history/LICENSE.md new file mode 100644 index 0000000..e9232eb --- /dev/null +++ b/ng-app/src/history/LICENSE.md @@ -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. diff --git a/ng-app/src/history/gitgraph-core/branch.ts b/ng-app/src/history/gitgraph-core/branch.ts new file mode 100644 index 0000000..e146a78 --- /dev/null +++ b/ng-app/src/history/gitgraph-core/branch.ts @@ -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 extends CommitRenderOptions { + author?: string; + subject?: string; + style?: TemplateOptions["commit"]; +} + +interface BranchRenderOptions { + renderLabel?: (branch: Branch) => TNode; +} + +interface BranchOptions extends BranchRenderOptions { + /** + * Gitgraph constructor + */ + gitgraph: GitgraphCore; + /** + * Branch name + */ + name: string; + /** + * Branch style + */ + style: BranchStyle; + /** + * Parent commit + */ + parentCommitHash?: Commit["hash"]; + /** + * Default options for commits + */ + commitDefaultOptions?: BranchCommitDefaultOptions; + /** + * On graph update. + */ + onGraphUpdate: () => void; +} + +const DELETED_BRANCH_NAME = ""; + +class Branch { + public name: BranchOptions["name"]; + public style: BranchStyle; + public index: number = 0; + public computedColor?: BranchStyle["color"]; + public parentCommitHash: BranchOptions["parentCommitHash"]; + public commitDefaultOptions: BranchCommitDefaultOptions; + public renderLabel: BranchOptions["renderLabel"]; + + private gitgraph: GitgraphCore; + private onGraphUpdate: () => void; + + constructor(options: BranchOptions) { + 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; + } + +} + diff --git a/ng-app/src/history/gitgraph-core/branches-paths.ts b/ng-app/src/history/gitgraph-core/branches-paths.ts new file mode 100644 index 0000000..b27736f --- /dev/null +++ b/ng-app/src/history/gitgraph-core/branches-paths.ts @@ -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 = Map, Coordinate[][]>; + +interface Coordinate { + x: number; + y: number; +} + +type InternalBranchesPaths = Map, 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 { + private commits: Array>; + private rows:GraphRows; + private branches: Map>; + private isGraphReverse: boolean; + private commitSpacing: CommitStyleBase["spacing"]; + private branchesPaths: InternalBranchesPaths = new Map< + Branch, + InternalPaths + >(); + + constructor( + commits: Array>, + rows: GraphRows, + branches: Map>, + 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 { + return this.fromCommits(); + // this.withMergeCommits(); + // return this.smoothBranchesPaths(); + } + + /** + * Initialize branches paths from calculator's commits. + */ + private fromCommits() : BranchesPaths { + 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 { + 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 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, 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 { + // const branchesPaths = new Map, 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( + // (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(" "); +} diff --git a/ng-app/src/history/gitgraph-core/commit.ts b/ng-app/src/history/gitgraph-core/commit.ts new file mode 100644 index 0000000..6b23af7 --- /dev/null +++ b/ng-app/src/history/gitgraph-core/commit.ts @@ -0,0 +1,275 @@ +import type { CommitStyle } from "./template"; +import type { Branch } from "./branch"; + +export { type CommitRenderOptions, type CommitOptions, Commit }; + +interface CommitRenderOptions { + renderDot?: (commit: Commit) => TNode; + renderMessage?: (commit: Commit) => TNode; + renderTooltip?: (commit: Commit) => TNode; +} + +interface CommitOptions extends CommitRenderOptions { + author: string; + subject: string; + style: CommitStyle; + x: number; + y: number; + body?: string; + hash?: string; + parents?: string[]; + dotText?: string; + branch?: Branch["name"]; + onClick?: (commit: Commit) => void; + onMessageClick?: (commit: Commit) => void; + onMouseOver?: (commit: Commit) => void; + onMouseOut?: (commit: Commit) => 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 { + + 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["hash"]>; + /** + * Abbreviated parent hashed + */ + public parentsAbbrev: Array["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; + /** + * Custom message render + */ + public renderMessage?: (commit: Commit) => TNode; + /** + * Custom tooltip render + */ + public renderTooltip?: (commit: Commit) => TNode; + + constructor(options: CommitOptions) { + // 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 { + 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; + } +} diff --git a/ng-app/src/history/gitgraph-core/gitgraph-user-api.ts b/ng-app/src/history/gitgraph-core/gitgraph-user-api.ts new file mode 100644 index 0000000..da15587 --- /dev/null +++ b/ng-app/src/history/gitgraph-core/gitgraph-user-api.ts @@ -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 extends CommitRenderOptions { + author?: string; + subject?: string; + body?: string; + hash?: string; + style?: TemplateOptions["commit"]; + dotText?: string; + tag?: string; + onClick?: (commit: Commit) => void; + onMessageClick?: (commit: Commit) => void; + onMouseOver?: (commit: Commit) => void; + onMouseOut?: (commit: Commit) => void; +} + +class GitgraphUserApi { + // tslint:disable:variable-name - Prefix `_` = explicitly private for JS users + private _graph: GitgraphCore; + private _onGraphUpdate: () => void; + // tslint:enable:variable-name + + constructor(graph: GitgraphCore, 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 = { + 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 & { 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 +} diff --git a/ng-app/src/history/gitgraph-core/gitgraph.ts b/ng-app/src/history/gitgraph-core/gitgraph.ts new file mode 100644 index 0000000..4b5c4d7 --- /dev/null +++ b/ng-app/src/history/gitgraph-core/gitgraph.ts @@ -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 { + private branches: Map>; + private colors: Color[]; + public constructor( + branches: Map>, + 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 { + commits: Array>; + branchesPaths: BranchesPaths; + commitMessagesX: number; +} + +class GitgraphCore { + 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; + + public commits: Array> = []; + public swimlanes: Array = []; + public last_on_swimlanes: Array = []; + public branches: Map> = new Map(); + + private listeners: Array<(data: RenderedData) => 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 { + 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) => 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 { + 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 { + + 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(options); + branch.index = this.branches.size; + this.branches.set(branch.name, branch); + return branch; + } + + /** + * Return commits with data for rendering. + */ + private computeRenderedCommits(): Array> { + //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[], commit: Commit) => + // 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 | 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( + 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, + 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>, + ): BranchesPaths { + return new BranchesPathsCalculator( + 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, + ): void { + const branchesOrder = new BranchesOrder( + 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): number { + return this.swimlanes.length * this.template.branch.spacing; + } + + /** + * Get all branches from current commits. + */ + // private getBranches(): Map> { + // const result = new Map>(); + // this.commits.forEach((commit) => { + // let r = new Set(); + // 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, + ): Commit { + //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); + } +} diff --git a/ng-app/src/history/gitgraph-core/graph-rows.ts b/ng-app/src/history/gitgraph-core/graph-rows.ts new file mode 100644 index 0000000..b0599e4 --- /dev/null +++ b/ng-app/src/history/gitgraph-core/graph-rows.ts @@ -0,0 +1,11 @@ +import type { Commit } from "./commit"; + +import { RegularGraphRows } from "./regular-graph-rows"; + +export { createGraphRows, RegularGraphRows as GraphRows }; + +function createGraphRows( + commits: Array>, +) { + return new RegularGraphRows(commits); +} diff --git a/ng-app/src/history/gitgraph-core/index.ts b/ng-app/src/history/gitgraph-core/index.ts new file mode 100644 index 0000000..9c117dd --- /dev/null +++ b/ng-app/src/history/gitgraph-core/index.ts @@ -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"; diff --git a/ng-app/src/history/gitgraph-core/regular-graph-rows.ts b/ng-app/src/history/gitgraph-core/regular-graph-rows.ts new file mode 100644 index 0000000..77d53f5 --- /dev/null +++ b/ng-app/src/history/gitgraph-core/regular-graph-rows.ts @@ -0,0 +1,46 @@ +import type { Commit } from "./commit"; + +export class RegularGraphRows { + protected rows = new Map(); + + private maxRowCache: number | undefined = undefined; + + public constructor(commits: Array>) { + 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>): 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(array: T[]): T[] { +// const set = new Set(); +// array.forEach((value) => set.add(value)); +// return Array.from(set); +// } diff --git a/ng-app/src/history/gitgraph-core/template.ts b/ng-app/src/history/gitgraph-core/template.ts new file mode 100644 index 0000000..96ed8a6 --- /dev/null +++ b/ng-app/src/history/gitgraph-core/template.ts @@ -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; + +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; + +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; + +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; + +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; + +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; + +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 { + /** + * 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; +} diff --git a/ng-app/src/history/gitgraph-core/utils.ts b/ng-app/src/history/gitgraph-core/utils.ts new file mode 100644 index 0000000..20ab31b --- /dev/null +++ b/ng-app/src/history/gitgraph-core/utils.ts @@ -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 = Pick>; + +/** + * Get all property names not matching a type. + * + * @ref http://tycho01.github.io/typical/modules/_object_nonmatchingpropsnames_.html + */ +type NonMatchingPropNames = { + [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 = Pick>; + +/** + * 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(obj: T, paths: K[]): Pick { + return { + ...paths.reduce((mem, key) => ({ ...mem, [key]: obj[key] }), {}), + } as Pick; +} + +/** + * Print a light version of commits into the console. + * @param commits List of commits + * @param paths The property paths to pick + */ +function debug( + commits: Array>, + paths: Array>, +): 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( + obj: T = {} as T, +): NonMatchingProp { + return (Object.keys(obj) as [keyof T]).reduce( + (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( + graph: GitgraphCore, + parent: Coordinate, + commit: Commit, +): 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( + graph: GitgraphCore, + parent: Coordinate, + commit: Commit, +): 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); +} diff --git a/ng-app/src/history/gitgraph-js/gitgraph.ts b/ng-app/src/history/gitgraph-js/gitgraph.ts new file mode 100644 index 0000000..a5fc3c0 --- /dev/null +++ b/ng-app/src/history/gitgraph-js/gitgraph.ts @@ -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; + +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; + 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): 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( + (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["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 { + 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 + 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; +} diff --git a/ng-app/src/history/gitgraph-js/svg-elements.ts b/ng-app/src/history/gitgraph-js/svg-elements.ts new file mode 100644 index 0000000..27c79ee --- /dev/null +++ b/ng-app/src/history/gitgraph-js/svg-elements.ts @@ -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; + 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; +} diff --git a/ng-app/src/history/gitgraph-js/tooltip.ts b/ng-app/src/history/gitgraph-js/tooltip.ts new file mode 100644 index 0000000..a69224e --- /dev/null +++ b/ng-app/src/history/gitgraph-js/tooltip.ts @@ -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; +} diff --git a/ng-app/src/lib/Test.svelte b/ng-app/src/lib/Test.svelte index 5652f36..0fd1079 100644 --- a/ng-app/src/lib/Test.svelte +++ b/ng-app/src/lib/Test.svelte @@ -10,6 +10,11 @@ -->
+
{#if $cannot_load_offline}

@@ -215,6 +712,15 @@ +