UNPKG

cheetah-grid

Version:

Cheetah Grid is a high performance grid engine that works on canvas

738 lines (703 loc) 17.2 kB
import type { BranchGraphColumnOption, BranchGraphCommandValue, CellContext, ColorDef, FieldDef, GridCanvasHelperAPI, ListGridAPI, MaybePromise, MaybePromiseOrUndef, } from "../../ts-types"; import type { DrawCellInfo, GridInternal } from "../../ts-types-internal"; import { getOrApply, isPromise, obj } from "../../internal/utils"; import { BaseColumn } from "./BaseColumn"; import { BranchGraphStyle } from "../style/BranchGraphStyle"; import { getBranchGraphColumnStateId } from "../../internal/symbolManager"; const _ = getBranchGraphColumnStateId(); type Timelines = { timeline: BranchPoint[][]; branches: string[] }; function getAllColumnData<T>( grid: ListGridAPI<T>, field: FieldDef<T>, callback: (allData: BranchGraphCommandValue[]) => void ): void { const { dataSource } = grid; const allData: BranchGraphCommandValue[] = []; let promise; for (let index = 0; index < dataSource.length; index++) { const data = dataSource.getField( index, field ) as MaybePromiseOrUndef<BranchGraphCommandValue>; if (isPromise(data)) { const dataIndex = allData.length; allData.push(undefined); if (!promise) { promise = data.then((d) => { allData[dataIndex] = d; }); } else { promise = promise .then(() => data) .then((d) => { allData[dataIndex] = d; }); } } else { allData.push(data); } } if (promise) { promise.then(() => callback(allData)); } else { callback(allData); } } class BranchLine { readonly fromIndex?: number; toIndex?: number; readonly colorIndex: number; readonly point?: BranchPoint; constructor({ fromIndex, toIndex, colorIndex, point, }: { fromIndex?: number; toIndex?: number; colorIndex: number; point?: BranchPoint; }) { this.fromIndex = fromIndex; this.toIndex = toIndex; this.colorIndex = colorIndex; this.point = point; } } class BranchPoint { readonly index: number; readonly commit: boolean; lines: BranchLine[]; readonly tag?: string; constructor({ index, commit = false, lines = [], tag, }: { index: number; commit?: boolean; lines?: BranchLine[]; tag?: string; }) { this.index = index; this.commit = commit; this.lines = lines; this.tag = tag; } static mergeLines(lines: BranchLine[]): BranchLine[] { const result = lines.filter( (l) => l.fromIndex != null && l.toIndex != null ); const fromList = lines.filter( (l) => l.fromIndex != null && l.toIndex == null ); const toList = lines.filter( (l) => l.fromIndex == null && l.toIndex != null ); fromList.forEach((f) => { for (let i = 0; i < toList.length; i++) { const t = toList[i]; if (t.point) { continue; } if (f.colorIndex === t.colorIndex) { f.toIndex = t.toIndex; toList.splice(i, 1); break; } } result.push(f); }); return result.concat(toList); } static merge(a: BranchPoint, b: BranchPoint): BranchPoint { if (!a) { return b; } return new BranchPoint({ index: a.index, commit: a.commit || b.commit, lines: BranchPoint.mergeLines(a.lines.concat(b.lines)), tag: a.tag || b.tag, }); } } function joinLine(timeline: BranchPoint[][], branchIndex: number): boolean { const reverse = [...timeline].reverse(); for (let i = 0; i < reverse.length; i++) { const f = reverse[i][branchIndex]; if (f) { f.lines = BranchPoint.mergeLines( f.lines.concat([ new BranchLine({ toIndex: branchIndex, colorIndex: branchIndex, }), ]) ); for (let j = 0; j < i; j++) { const tl = reverse[j]; tl[branchIndex] = new BranchPoint({ index: branchIndex, lines: [ new BranchLine({ fromIndex: branchIndex, toIndex: branchIndex, colorIndex: branchIndex, }), ], }); } return true; } } return false; } function branch( { timeline, branches }: Timelines, from: string | null, to: string ): BranchPoint | null { const fromIndex = from != null ? branches.indexOf(from) : -1; let toIndex = branches.indexOf(to); if (toIndex < 0) { toIndex = branches.length; branches.push(to); } function findBranchRootIndex(): number { for (let index = timeline.length - 1; index >= 0; index--) { const tl = timeline[index]; const from = tl[fromIndex]; if (from && from.commit) { return index; } } return -1; } if (fromIndex < 0) { return new BranchPoint({ index: toIndex, }); } else { const fromTargetIndex = findBranchRootIndex(); if (fromTargetIndex === -1) { return null; } const branchTargetFromIndex = fromTargetIndex + 1; const branchPoint = new BranchPoint({ index: toIndex, lines: [ new BranchLine({ fromIndex, colorIndex: toIndex, }), ], }); let point; let result = null; if (branchTargetFromIndex < timeline.length) { const targetLine = timeline[branchTargetFromIndex]; point = targetLine[toIndex] = BranchPoint.merge( targetLine[toIndex], branchPoint ); } else { point = branchPoint; result = branchPoint; } const from = timeline[fromTargetIndex][fromIndex]; from.lines = BranchPoint.mergeLines( from.lines.concat([ new BranchLine({ toIndex, colorIndex: toIndex, point, }), ]) ); return result; } } function commit( { timeline, branches }: Timelines, name: string ): BranchPoint | null { const index = branches.indexOf(name); if (index < 0) { return null; } const result = new BranchPoint({ index, commit: true, }); if (joinLine(timeline, index)) { result.lines = BranchPoint.mergeLines( result.lines.concat([ new BranchLine({ fromIndex: index, colorIndex: index, }), ]) ); } return result; } function commitTag( { branches }: Timelines, name: string, tag: string ): BranchPoint { let index = branches.indexOf(name); if (index < 0) { index = branches.length; branches.push(name); } return new BranchPoint({ index, tag, }); } function commitMerge( { timeline, branches }: Timelines, from: string, to: string ): BranchPoint { const fromIndex = branches.indexOf(from); const toIndex = branches.indexOf(to); if (toIndex < 0 || fromIndex < 0) { return new BranchPoint({ index: toIndex, commit: true, }); } const result = new BranchPoint({ index: toIndex, commit: true, lines: [ new BranchLine({ fromIndex, colorIndex: fromIndex, }), new BranchLine({ fromIndex: toIndex, colorIndex: toIndex, }), ], }); const fromList = [...timeline]; const fromTargetLine = fromList.pop(); if (fromTargetLine) { fromTargetLine[fromIndex] = BranchPoint.merge( fromTargetLine[fromIndex], new BranchPoint({ index: toIndex, lines: [ new BranchLine({ toIndex, colorIndex: fromIndex, }), ], }) ); } if (joinLine(fromList, fromIndex) && fromTargetLine) { fromTargetLine[fromIndex].lines = BranchPoint.mergeLines( fromTargetLine[fromIndex].lines.concat([ new BranchLine({ fromIndex, colorIndex: fromIndex, }), ]) ); } joinLine(timeline, toIndex); return result; } function calcCommand(info: Timelines, command: BranchGraphCommandValue): void { const { timeline } = info; const timelineData: BranchPoint[] = []; // const last = timeline.length > 0 ? timeline[timeline.length - 1] : null; const commands = Array.isArray(command) ? command : [command]; commands.forEach((cmd) => { if (!cmd) { return; } let point; if (cmd.command === "branch") { const from = obj.isObject(cmd.branch) ? cmd.branch.from : null; const to = obj.isObject(cmd.branch) ? cmd.branch.to : cmd.branch; point = branch(info, from, to); } else if (cmd.command === "commit") { const { branch } = cmd; point = commit(info, branch); } else if (cmd.command === "merge") { const { from, to } = cmd.branch; point = commitMerge(info, from, to); } else if (cmd.command === "tag") { const { branch, tag } = cmd; point = commitTag(info, branch, tag); } if (point && point.index > -1) { timelineData[point.index] = BranchPoint.merge( timelineData[point.index], point ); } }); timeline.push(timelineData); } function calcBranchesInfo<T>( start: "top" | "bottom", grid: ListGridAPI<T>, field: FieldDef<T> ): Timelines { const result = { branches: [], timeline: [], }; getAllColumnData(grid, field, (data) => { if (start !== "top") { data = [...data].reverse(); } data.forEach((command) => { calcCommand(result, command); }); }); return result; } function calcBranchXPoints( ctx: CanvasRenderingContext2D, left: number, width: number, radius: number, branches: string[], timeline: BranchPoint[][] ): number[] { let w = Math.max(width / branches.length + 1, 5); timeline.forEach((tl) => { tl.forEach((p, index) => { if (index <= 0) { // 計算の意味が無い return; } if (p.tag) { const textWidth = ctx.measureText(p.tag).width; if (w * index + radius * 2 + 4 + textWidth > width) { w = Math.max((width - radius * 2 - 4 - textWidth) / index, 5); } } }); }); const result: number[] = []; let x = left; branches.forEach(() => { result.push(Math.ceil(x + radius)); x += w; }); return result; } function renderMerge<T>( grid: ListGridAPI<T>, ctx: CanvasRenderingContext2D, x: number, y: number, upLineIndex: number | undefined, downLineIndex: number | undefined, colorIndex: number, { branchXPoints, // margin, branchColors, branchLineWidth, mergeStyle, }: { branchXPoints: number[]; margin: number; branchColors: ColorDef | ((name: string, index: number) => ColorDef); branchLineWidth: number; mergeStyle: "straight" | "bezier"; }, { // width, col, row, branches, }: { width: number; col: number; row: number; branches: string[]; } ): void { if (upLineIndex != null || downLineIndex != null) { ctx.strokeStyle = getOrApply( branchColors, branches[colorIndex], colorIndex ); ctx.lineWidth = branchLineWidth; ctx.lineCap = "round"; ctx.beginPath(); if (upLineIndex != null) { const upX = branchXPoints[upLineIndex]; const upRect = grid.getCellRelativeRect(col, row - 1); const upY = upRect.top + upRect.height / 2; ctx.moveTo(upX, upY); if (mergeStyle === "bezier") { ctx.bezierCurveTo(upX, (y + upY) / 2, x, (y + upY) / 2, x, y); } else { ctx.lineTo(x, y); } } else { ctx.moveTo(x, y); } if (downLineIndex != null) { const downX = branchXPoints[downLineIndex]; const downRect = grid.getCellRelativeRect(col, row + 1); const downY = downRect.top + downRect.height / 2; if (mergeStyle === "bezier") { ctx.bezierCurveTo( x, (y + downY) / 2, downX, (y + downY) / 2, downX, downY ); } else { ctx.lineTo(downX, downY); } } ctx.stroke(); } } /** * BranchGraphColumn * * Data commands * - master branch or orphan branch * * ```js * { * command: 'branch', * branch: 'branch name A', * } * ``` * * - commit * * ```js * { * command: 'commit', * branch: 'branch name A' * } * ``` * * - branch * * ```js * { * command: 'branch', * branch: { * from: 'branch name A', * to: 'branch name B' * } * } * ``` * * - merge * * ```js * { * command: 'merge', * branch: { * from: 'branch name B', * to: 'branch name A' * } * } * ``` * * - tag * * ```js * { * command: 'tag', * branch: 'branch name A', * tag: 'tag name' * } * ``` * * @memberof cheetahGrid.columns.type */ export class BranchGraphColumn<T> extends BaseColumn<T> { private _start: "top" | "bottom"; private _cache: boolean; constructor(option: BranchGraphColumnOption = {}) { super(option); this._start = option.start || "bottom"; this._cache = option.cache != null ? option.cache : false; } get StyleClass(): typeof BranchGraphStyle { return BranchGraphStyle; } clearCache(grid: ListGridAPI<T>): void { const internal = grid as GridInternal<T>; delete internal[_]; } onDrawCell( cellValue: MaybePromise<unknown>, info: DrawCellInfo<T>, context: CellContext, grid: GridInternal<T> ): void | Promise<void> { if (this._cache) { const state = grid[_] || (grid[_] = new Map()); const { col, row } = context; const field = grid.getField(col, row) as FieldDef<T>; if (!state.has(field)) { state.set(field, calcBranchesInfo(this._start, grid, field)); } } return super.onDrawCell(cellValue, info, context, grid); } clone(): BranchGraphColumn<T> { return new BranchGraphColumn(this); } get start(): "top" | "bottom" { return this._start; } get cache(): boolean { return this._cache; } drawInternal( _value: unknown, context: CellContext, style: BranchGraphStyle, helper: GridCanvasHelperAPI, grid: GridInternal<T>, { drawCellBase }: DrawCellInfo<T> ): void { const { col, row } = context; const field = grid.getField(col, row) as FieldDef<T>; const { timeline, branches } = (this._cache ? grid[_]?.get(field) : null) ?? calcBranchesInfo(this._start, grid, field); const { upLineIndexKey, downLineIndexKey, }: { upLineIndexKey: "fromIndex" | "toIndex"; downLineIndexKey: "fromIndex" | "toIndex"; } = this._start !== "top" ? { upLineIndexKey: "toIndex", downLineIndexKey: "fromIndex" } : { upLineIndexKey: "fromIndex", downLineIndexKey: "toIndex" }; const data = this._start !== "top" ? timeline[timeline.length - (row - grid.frozenRowCount) - 1] : timeline[row - grid.frozenRowCount]; const { branchColors, branchLineWidth, circleSize, mergeStyle, margin, bgColor, visibility, } = style; if (bgColor) { drawCellBase({ bgColor, }); } if (visibility === "hidden") { return; } const rect = context.getRect(); const radius = circleSize / 2; const width = rect.width - margin * 2; helper.drawWithClip(context, (ctx) => { ctx.textAlign = "left"; ctx.textBaseline = "middle"; const branchXPoints = calcBranchXPoints( ctx, rect.left + margin, width, radius, branches, timeline ); const y = rect.top + rect.height / 2; // draw join lines data .map((point, index) => point ? point.lines.map((line) => ({ colorIndex: line.colorIndex, upLineIndex: line[upLineIndexKey], downLineIndex: line[downLineIndexKey], pointIndex: index, })) : [] ) .reduce((p, c) => p.concat(c), []) // flatMap // order of overlap .sort((a, b) => b.colorIndex - a.colorIndex) .forEach((line) => { const x = branchXPoints[line.pointIndex]; renderMerge( grid, ctx, x, y, line.upLineIndex, line.downLineIndex, line.colorIndex, { margin, branchXPoints, branchLineWidth, branchColors, mergeStyle, }, { width, col, row, branches, } ); }); // draw commit points data.forEach((p, index) => { if (p && p.commit) { const x = branchXPoints[index]; ctx.fillStyle = getOrApply(branchColors, branches[index], index); ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2, true); ctx.fill(); ctx.closePath(); } }); // draw tags data.forEach((p, index) => { if (p && p.tag) { ctx.fillStyle = getOrApply(branchColors, branches[index], index); ctx.fillText(p.tag, branchXPoints[index] + radius + 4, y); } }); }); } }