Rendering the git graph on the server means that we can properly track flows and switch from the Canvas implementation to a SVG implementation. * This implementation provides a 16 limited color selection * The uniqued color numbers are also provided * And there is also a monochrome version *In addition is a hover highlight that allows users to highlight commits on the same flow. Closes #12209 Signed-off-by: Andrew Thornton art27@cantab.net Co-authored-by: silverwind <me@silverwind.io>for-closed-social
@ -0,0 +1,186 @@ | |||
// Copyright 2020 The Gitea Authors. All rights reserved. | |||
// Use of this source code is governed by a MIT-style | |||
// license that can be found in the LICENSE file. | |||
package gitgraph | |||
import ( | |||
"bytes" | |||
"fmt" | |||
) | |||
// NewGraph creates a basic graph | |||
func NewGraph() *Graph { | |||
graph := &Graph{} | |||
graph.relationCommit = &Commit{ | |||
Row: -1, | |||
Column: -1, | |||
} | |||
graph.Flows = map[int64]*Flow{} | |||
return graph | |||
} | |||
// Graph represents a collection of flows | |||
type Graph struct { | |||
Flows map[int64]*Flow | |||
Commits []*Commit | |||
MinRow int | |||
MinColumn int | |||
MaxRow int | |||
MaxColumn int | |||
relationCommit *Commit | |||
} | |||
// Width returns the width of the graph | |||
func (graph *Graph) Width() int { | |||
return graph.MaxColumn - graph.MinColumn + 1 | |||
} | |||
// Height returns the height of the graph | |||
func (graph *Graph) Height() int { | |||
return graph.MaxRow - graph.MinRow + 1 | |||
} | |||
// AddGlyph adds glyph to flows | |||
func (graph *Graph) AddGlyph(row, column int, flowID int64, color int, glyph byte) { | |||
flow, ok := graph.Flows[flowID] | |||
if !ok { | |||
flow = NewFlow(flowID, color, row, column) | |||
graph.Flows[flowID] = flow | |||
} | |||
flow.AddGlyph(row, column, glyph) | |||
if row < graph.MinRow { | |||
graph.MinRow = row | |||
} | |||
if row > graph.MaxRow { | |||
graph.MaxRow = row | |||
} | |||
if column < graph.MinColumn { | |||
graph.MinColumn = column | |||
} | |||
if column > graph.MaxColumn { | |||
graph.MaxColumn = column | |||
} | |||
} | |||
// AddCommit adds a commit at row, column on flowID with the provided data | |||
func (graph *Graph) AddCommit(row, column int, flowID int64, data []byte) error { | |||
commit, err := NewCommit(row, column, data) | |||
if err != nil { | |||
return err | |||
} | |||
commit.Flow = flowID | |||
graph.Commits = append(graph.Commits, commit) | |||
graph.Flows[flowID].Commits = append(graph.Flows[flowID].Commits, commit) | |||
return nil | |||
} | |||
// NewFlow creates a new flow | |||
func NewFlow(flowID int64, color, row, column int) *Flow { | |||
return &Flow{ | |||
ID: flowID, | |||
ColorNumber: color, | |||
MinRow: row, | |||
MinColumn: column, | |||
MaxRow: row, | |||
MaxColumn: column, | |||
} | |||
} | |||
// Flow represents a series of glyphs | |||
type Flow struct { | |||
ID int64 | |||
ColorNumber int | |||
Glyphs []Glyph | |||
Commits []*Commit | |||
MinRow int | |||
MinColumn int | |||
MaxRow int | |||
MaxColumn int | |||
} | |||
// Color16 wraps the color numbers around mod 16 | |||
func (flow *Flow) Color16() int { | |||
return flow.ColorNumber % 16 | |||
} | |||
// AddGlyph adds glyph at row and column | |||
func (flow *Flow) AddGlyph(row, column int, glyph byte) { | |||
if row < flow.MinRow { | |||
flow.MinRow = row | |||
} | |||
if row > flow.MaxRow { | |||
flow.MaxRow = row | |||
} | |||
if column < flow.MinColumn { | |||
flow.MinColumn = column | |||
} | |||
if column > flow.MaxColumn { | |||
flow.MaxColumn = column | |||
} | |||
flow.Glyphs = append(flow.Glyphs, Glyph{ | |||
row, | |||
column, | |||
glyph, | |||
}) | |||
} | |||
// Glyph represents a co-ordinate and glyph | |||
type Glyph struct { | |||
Row int | |||
Column int | |||
Glyph byte | |||
} | |||
// RelationCommit represents an empty relation commit | |||
var RelationCommit = &Commit{ | |||
Row: -1, | |||
} | |||
// NewCommit creates a new commit from a provided line | |||
func NewCommit(row, column int, line []byte) (*Commit, error) { | |||
data := bytes.SplitN(line, []byte("|"), 7) | |||
if len(data) < 7 { | |||
return nil, fmt.Errorf("malformed data section on line %d with commit: %s", row, string(line)) | |||
} | |||
return &Commit{ | |||
Row: row, | |||
Column: column, | |||
// 0 matches git log --pretty=format:%d => ref names, like the --decorate option of git-log(1) | |||
Branch: string(data[0]), | |||
// 1 matches git log --pretty=format:%H => commit hash | |||
Rev: string(data[1]), | |||
// 2 matches git log --pretty=format:%ad => author date (format respects --date= option) | |||
Date: string(data[2]), | |||
// 3 matches git log --pretty=format:%an => author name | |||
Author: string(data[3]), | |||
// 4 matches git log --pretty=format:%ae => author email | |||
AuthorEmail: string(data[4]), | |||
// 5 matches git log --pretty=format:%h => abbreviated commit hash | |||
ShortRev: string(data[5]), | |||
// 6 matches git log --pretty=format:%s => subject | |||
Subject: string(data[6]), | |||
}, nil | |||
} | |||
// Commit represents a commit at co-ordinate X, Y with the data | |||
type Commit struct { | |||
Flow int64 | |||
Row int | |||
Column int | |||
Branch string | |||
Rev string | |||
Date string | |||
Author string | |||
AuthorEmail string | |||
ShortRev string | |||
Subject string | |||
} | |||
// OnlyRelation returns whether this a relation only commit | |||
func (c *Commit) OnlyRelation() bool { | |||
return c.Row == -1 | |||
} |
@ -0,0 +1,338 @@ | |||
// Copyright 2020 The Gitea Authors. All rights reserved. | |||
// Use of this source code is governed by a MIT-style | |||
// license that can be found in the LICENSE file. | |||
package gitgraph | |||
import ( | |||
"bytes" | |||
"fmt" | |||
) | |||
// Parser represents a git graph parser. It is stateful containing the previous | |||
// glyphs, detected flows and color assignments. | |||
type Parser struct { | |||
glyphs []byte | |||
oldGlyphs []byte | |||
flows []int64 | |||
oldFlows []int64 | |||
maxFlow int64 | |||
colors []int | |||
oldColors []int | |||
availableColors []int | |||
nextAvailable int | |||
firstInUse int | |||
firstAvailable int | |||
maxAllowedColors int | |||
} | |||
// Reset resets the internal parser state. | |||
func (parser *Parser) Reset() { | |||
parser.glyphs = parser.glyphs[0:0] | |||
parser.oldGlyphs = parser.oldGlyphs[0:0] | |||
parser.flows = parser.flows[0:0] | |||
parser.oldFlows = parser.oldFlows[0:0] | |||
parser.maxFlow = 0 | |||
parser.colors = parser.colors[0:0] | |||
parser.oldColors = parser.oldColors[0:0] | |||
parser.availableColors = parser.availableColors[0:0] | |||
parser.availableColors = append(parser.availableColors, 1, 2) | |||
parser.nextAvailable = 0 | |||
parser.firstInUse = -1 | |||
parser.firstAvailable = 0 | |||
parser.maxAllowedColors = 0 | |||
} | |||
// AddLineToGraph adds the line as a row to the graph | |||
func (parser *Parser) AddLineToGraph(graph *Graph, row int, line []byte) error { | |||
idx := bytes.Index(line, []byte("DATA:")) | |||
if idx < 0 { | |||
parser.ParseGlyphs(line) | |||
} else { | |||
parser.ParseGlyphs(line[:idx]) | |||
} | |||
var err error | |||
commitDone := false | |||
for column, glyph := range parser.glyphs { | |||
if glyph == ' ' { | |||
continue | |||
} | |||
flowID := parser.flows[column] | |||
graph.AddGlyph(row, column, flowID, parser.colors[column], glyph) | |||
if glyph == '*' { | |||
if commitDone { | |||
if err != nil { | |||
err = fmt.Errorf("double commit on line %d: %s. %w", row, string(line), err) | |||
} else { | |||
err = fmt.Errorf("double commit on line %d: %s", row, string(line)) | |||
} | |||
} | |||
commitDone = true | |||
if idx < 0 { | |||
if err != nil { | |||
err = fmt.Errorf("missing data section on line %d with commit: %s. %w", row, string(line), err) | |||
} else { | |||
err = fmt.Errorf("missing data section on line %d with commit: %s", row, string(line)) | |||
} | |||
continue | |||
} | |||
err2 := graph.AddCommit(row, column, flowID, line[idx+5:]) | |||
if err != nil && err2 != nil { | |||
err = fmt.Errorf("%v %w", err2, err) | |||
continue | |||
} else if err2 != nil { | |||
err = err2 | |||
continue | |||
} | |||
} | |||
} | |||
if !commitDone { | |||
graph.Commits = append(graph.Commits, RelationCommit) | |||
} | |||
return err | |||
} | |||
func (parser *Parser) releaseUnusedColors() { | |||
if parser.firstInUse > -1 { | |||
// Here we step through the old colors, searching for them in the | |||
// "in-use" section of availableColors (that is, the colors between | |||
// firstInUse and firstAvailable) | |||
// Ensure that the benchmarks are not worsened with proposed changes | |||
stepstaken := 0 | |||
position := parser.firstInUse | |||
for _, color := range parser.oldColors { | |||
if color == 0 { | |||
continue | |||
} | |||
found := false | |||
i := position | |||
for j := stepstaken; i != parser.firstAvailable && j < len(parser.availableColors); j++ { | |||
colorToCheck := parser.availableColors[i] | |||
if colorToCheck == color { | |||
found = true | |||
break | |||
} | |||
i = (i + 1) % len(parser.availableColors) | |||
} | |||
if !found { | |||
// Duplicate color | |||
continue | |||
} | |||
// Swap them around | |||
parser.availableColors[position], parser.availableColors[i] = parser.availableColors[i], parser.availableColors[position] | |||
stepstaken++ | |||
position = (parser.firstInUse + stepstaken) % len(parser.availableColors) | |||
if position == parser.firstAvailable || stepstaken == len(parser.availableColors) { | |||
break | |||
} | |||
} | |||
if stepstaken == len(parser.availableColors) { | |||
parser.firstAvailable = -1 | |||
} else { | |||
parser.firstAvailable = position | |||
if parser.nextAvailable == -1 { | |||
parser.nextAvailable = parser.firstAvailable | |||
} | |||
} | |||
} | |||
} | |||
// ParseGlyphs parses the provided glyphs and sets the internal state | |||
func (parser *Parser) ParseGlyphs(glyphs []byte) { | |||
// Clean state for parsing this row | |||
parser.glyphs, parser.oldGlyphs = parser.oldGlyphs, parser.glyphs | |||
parser.glyphs = parser.glyphs[0:0] | |||
parser.flows, parser.oldFlows = parser.oldFlows, parser.flows | |||
parser.flows = parser.flows[0:0] | |||
parser.colors, parser.oldColors = parser.oldColors, parser.colors | |||
// Ensure we have enough flows and colors | |||
parser.colors = parser.colors[0:0] | |||
for range glyphs { | |||
parser.flows = append(parser.flows, 0) | |||
parser.colors = append(parser.colors, 0) | |||
} | |||
// Copy the provided glyphs in to state.glyphs for safekeeping | |||
parser.glyphs = append(parser.glyphs, glyphs...) | |||
// release unused colors | |||
parser.releaseUnusedColors() | |||
for i := len(glyphs) - 1; i >= 0; i-- { | |||
glyph := glyphs[i] | |||
switch glyph { | |||
case '|': | |||
fallthrough | |||
case '*': | |||
parser.setUpFlow(i) | |||
case '/': | |||
parser.setOutFlow(i) | |||
case '\\': | |||
parser.setInFlow(i) | |||
case '_': | |||
parser.setRightFlow(i) | |||
case '.': | |||
fallthrough | |||
case '-': | |||
parser.setLeftFlow(i) | |||
case ' ': | |||
// no-op | |||
default: | |||
parser.newFlow(i) | |||
} | |||
} | |||
} | |||
func (parser *Parser) takePreviousFlow(i, j int) { | |||
if j < len(parser.oldFlows) && parser.oldFlows[j] > 0 { | |||
parser.flows[i] = parser.oldFlows[j] | |||
parser.oldFlows[j] = 0 | |||
parser.colors[i] = parser.oldColors[j] | |||
parser.oldColors[j] = 0 | |||
} else { | |||
parser.newFlow(i) | |||
} | |||
} | |||
func (parser *Parser) takeCurrentFlow(i, j int) { | |||
if j < len(parser.flows) && parser.flows[j] > 0 { | |||
parser.flows[i] = parser.flows[j] | |||
parser.colors[i] = parser.colors[j] | |||
} else { | |||
parser.newFlow(i) | |||
} | |||
} | |||
func (parser *Parser) newFlow(i int) { | |||
parser.maxFlow++ | |||
parser.flows[i] = parser.maxFlow | |||
// Now give this flow a color | |||
if parser.nextAvailable == -1 { | |||
next := len(parser.availableColors) | |||
if parser.maxAllowedColors < 1 || next < parser.maxAllowedColors { | |||
parser.nextAvailable = next | |||
parser.firstAvailable = next | |||
parser.availableColors = append(parser.availableColors, next+1) | |||
} | |||
} | |||
parser.colors[i] = parser.availableColors[parser.nextAvailable] | |||
if parser.firstInUse == -1 { | |||
parser.firstInUse = parser.nextAvailable | |||
} | |||
parser.availableColors[parser.firstAvailable], parser.availableColors[parser.nextAvailable] = parser.availableColors[parser.nextAvailable], parser.availableColors[parser.firstAvailable] | |||
parser.nextAvailable = (parser.nextAvailable + 1) % len(parser.availableColors) | |||
parser.firstAvailable = (parser.firstAvailable + 1) % len(parser.availableColors) | |||
if parser.nextAvailable == parser.firstInUse { | |||
parser.nextAvailable = parser.firstAvailable | |||
} | |||
if parser.nextAvailable == parser.firstInUse { | |||
parser.nextAvailable = -1 | |||
parser.firstAvailable = -1 | |||
} | |||
} | |||
// setUpFlow handles '|' or '*' | |||
func (parser *Parser) setUpFlow(i int) { | |||
// In preference order: | |||
// | |||
// Previous Row: '\? ' ' |' ' /' | |||
// Current Row: ' | ' ' |' ' | ' | |||
if i > 0 && i-1 < len(parser.oldGlyphs) && parser.oldGlyphs[i-1] == '\\' { | |||
parser.takePreviousFlow(i, i-1) | |||
} else if i < len(parser.oldGlyphs) && (parser.oldGlyphs[i] == '|' || parser.oldGlyphs[i] == '*') { | |||
parser.takePreviousFlow(i, i) | |||
} else if i+1 < len(parser.oldGlyphs) && parser.oldGlyphs[i+1] == '/' { | |||
parser.takePreviousFlow(i, i+1) | |||
} else { | |||
parser.newFlow(i) | |||
} | |||
} | |||
// setOutFlow handles '/' | |||
func (parser *Parser) setOutFlow(i int) { | |||
// In preference order: | |||
// | |||
// Previous Row: ' |/' ' |_' ' |' ' /' ' _' '\' | |||
// Current Row: '/| ' '/| ' '/ ' '/ ' '/ ' '/' | |||
if i+2 < len(parser.oldGlyphs) && | |||
(parser.oldGlyphs[i+1] == '|' || parser.oldGlyphs[i+1] == '*') && | |||
(parser.oldGlyphs[i+2] == '/' || parser.oldGlyphs[i+2] == '_') && | |||
i+1 < len(parser.glyphs) && | |||
(parser.glyphs[i+1] == '|' || parser.glyphs[i+1] == '*') { | |||
parser.takePreviousFlow(i, i+2) | |||
} else if i+1 < len(parser.oldGlyphs) && | |||
(parser.oldGlyphs[i+1] == '|' || parser.oldGlyphs[i+1] == '*' || | |||
parser.oldGlyphs[i+1] == '/' || parser.oldGlyphs[i+1] == '_') { | |||
parser.takePreviousFlow(i, i+1) | |||
if parser.oldGlyphs[i+1] == '/' { | |||
parser.glyphs[i] = '|' | |||
} | |||
} else if i < len(parser.oldGlyphs) && parser.oldGlyphs[i] == '\\' { | |||
parser.takePreviousFlow(i, i) | |||
} else { | |||
parser.newFlow(i) | |||
} | |||
} | |||
// setInFlow handles '\' | |||
func (parser *Parser) setInFlow(i int) { | |||
// In preference order: | |||
// | |||
// Previous Row: '| ' '-. ' '| ' '\ ' '/' '---' | |||
// Current Row: '|\' ' \' ' \' ' \' '\' ' \ ' | |||
if i > 0 && i-1 < len(parser.oldGlyphs) && | |||
(parser.oldGlyphs[i-1] == '|' || parser.oldGlyphs[i-1] == '*') && | |||
(parser.glyphs[i-1] == '|' || parser.glyphs[i-1] == '*') { | |||
parser.newFlow(i) | |||
} else if i > 0 && i-1 < len(parser.oldGlyphs) && | |||
(parser.oldGlyphs[i-1] == '|' || parser.oldGlyphs[i-1] == '*' || | |||
parser.oldGlyphs[i-1] == '.' || parser.oldGlyphs[i-1] == '\\') { | |||
parser.takePreviousFlow(i, i-1) | |||
if parser.oldGlyphs[i-1] == '\\' { | |||
parser.glyphs[i] = '|' | |||
} | |||
} else if i < len(parser.oldGlyphs) && parser.oldGlyphs[i] == '/' { | |||
parser.takePreviousFlow(i, i) | |||
} else { | |||
parser.newFlow(i) | |||
} | |||
} | |||
// setRightFlow handles '_' | |||
func (parser *Parser) setRightFlow(i int) { | |||
// In preference order: | |||
// | |||
// Current Row: '__' '_/' '_|_' '_|/' | |||
if i+1 < len(parser.glyphs) && | |||
(parser.glyphs[i+1] == '_' || parser.glyphs[i+1] == '/') { | |||
parser.takeCurrentFlow(i, i+1) | |||
} else if i+2 < len(parser.glyphs) && | |||
(parser.glyphs[i+1] == '|' || parser.glyphs[i+1] == '*') && | |||
(parser.glyphs[i+2] == '_' || parser.glyphs[i+2] == '/') { | |||
parser.takeCurrentFlow(i, i+2) | |||
} else { | |||
parser.newFlow(i) | |||
} | |||
} | |||
// setLeftFlow handles '----.' | |||
func (parser *Parser) setLeftFlow(i int) { | |||
if parser.glyphs[i] == '.' { | |||
parser.newFlow(i) | |||
} else if i+1 < len(parser.glyphs) && | |||
(parser.glyphs[i+1] == '-' || parser.glyphs[i+1] == '.') { | |||
parser.takeCurrentFlow(i, i+1) | |||
} else { | |||
parser.newFlow(i) | |||
} | |||
} |
@ -0,0 +1 @@ | |||
<svg viewBox="0 0 768 768" class="svg material-invert-colors" width="16" height="16" aria-hidden="true"><path d="M384 627V163.5l-135 135c-36 36-57 85.5-57 136.5 0 103.19 88.8 192 192 192zm181.5-373.5C666 354 666 514.5 565.5 615 516 664.5 450 690 384 690s-132-25.5-181.5-75C102 514.5 102 354 202.5 253.5L384 72z"/></svg> |
@ -0,0 +1 @@ | |||
<svg viewBox="0 0 768 768" class="svg material-palette" width="16" height="16" aria-hidden="true"><path d="M559.5 384c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zm-96-127.5c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zm-159 0c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zm-96 127.5c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zM384 96c159 0 288 115.5 288 256.5 0 88.5-72 159-160.5 159H456c-27 0-48 21-48 48 0 12 4.5 22.5 12 31.5s12 21 12 33c0 27-21 48-48 48-159 0-288-129-288-288S225 96 384 96z"/></svg> |
@ -1,568 +1,81 @@ | |||
// Although inspired by the https://github.com/bluef/gitgraph.js/blob/master/gitgraph.js | |||
// this has been completely rewritten with almost no remaining code | |||
// GitGraphCanvas is a canvas for drawing gitgraphs on to | |||
class GitGraphCanvas { | |||
constructor(canvas, widthUnits, heightUnits, config) { | |||
this.ctx = canvas.getContext('2d'); | |||
const width = widthUnits * config.unitSize; | |||
this.height = heightUnits * config.unitSize; | |||
const ratio = window.devicePixelRatio || 1; | |||
canvas.width = width * ratio; | |||
canvas.height = this.height * ratio; | |||
canvas.style.width = `${width}px`; | |||
canvas.style.height = `${this.height}px`; | |||
this.ctx.lineWidth = config.lineWidth; | |||
this.ctx.lineJoin = 'round'; | |||
this.ctx.lineCap = 'round'; | |||
this.ctx.scale(ratio, ratio); | |||
this.config = config; | |||
} | |||
drawLine(moveX, moveY, lineX, lineY, color) { | |||
this.ctx.strokeStyle = color; | |||
this.ctx.beginPath(); | |||
this.ctx.moveTo(moveX, moveY); | |||
this.ctx.lineTo(lineX, lineY); | |||
this.ctx.stroke(); | |||
} | |||
drawLineRight(x, y, color) { | |||
this.drawLine( | |||
x - 0.5 * this.config.unitSize, | |||
y + this.config.unitSize / 2, | |||
x + 0.5 * this.config.unitSize, | |||
y + this.config.unitSize / 2, | |||
color | |||
); | |||
} | |||
drawLineUp(x, y, color) { | |||
this.drawLine( | |||
x, | |||
y + this.config.unitSize / 2, | |||
x, | |||
y - this.config.unitSize / 2, | |||
color | |||
); | |||
} | |||
drawNode(x, y, color) { | |||
this.ctx.strokeStyle = color; | |||
this.drawLineUp(x, y, color); | |||
this.ctx.beginPath(); | |||
this.ctx.arc(x, y, this.config.nodeRadius, 0, Math.PI * 2, true); | |||
this.ctx.fillStyle = color; | |||
this.ctx.fill(); | |||
} | |||
drawLineIn(x, y, color) { | |||
this.drawLine( | |||
x + 0.5 * this.config.unitSize, | |||
y + this.config.unitSize / 2, | |||
x - 0.5 * this.config.unitSize, | |||
y - this.config.unitSize / 2, | |||
color | |||
); | |||
} | |||
drawLineOut(x, y, color) { | |||
this.drawLine( | |||
x - 0.5 * this.config.unitSize, | |||
y + this.config.unitSize / 2, | |||
x + 0.5 * this.config.unitSize, | |||
y - this.config.unitSize / 2, | |||
color | |||
); | |||
} | |||
drawSymbol(symbol, columnNumber, rowNumber, color) { | |||
const y = this.height - this.config.unitSize * (rowNumber + 0.5); | |||
const x = this.config.unitSize * 0.5 * (columnNumber + 1); | |||
switch (symbol) { | |||
case '-': | |||
if (columnNumber % 2 === 1) { | |||
this.drawLineRight(x, y, color); | |||
} | |||
break; | |||
case '_': | |||
this.drawLineRight(x, y, color); | |||
break; | |||
case '*': | |||
this.drawNode(x, y, color); | |||
break; | |||
case '|': | |||
this.drawLineUp(x, y, color); | |||
break; | |||
case '/': | |||
this.drawLineOut(x, y, color); | |||
break; | |||
case '\\': | |||
this.drawLineIn(x, y, color); | |||
break; | |||
case '.': | |||
case ' ': | |||
break; | |||
default: | |||
console.error('Unknown symbol', symbol, color); | |||
} | |||
} | |||
} | |||
class GitGraph { | |||
constructor(canvas, rawRows, config) { | |||
this.rows = []; | |||
let maxWidth = 0; | |||
for (let i = 0; i < rawRows.length; i++) { | |||
const rowStr = rawRows[i]; | |||
maxWidth = Math.max(rowStr.replace(/([_\s.-])/g, '').length, maxWidth); | |||
const rowArray = rowStr.split(''); | |||
this.rows.unshift(rowArray); | |||
} | |||
this.currentFlows = []; | |||
this.previousFlows = []; | |||
this.gitGraphCanvas = new GitGraphCanvas( | |||
canvas, | |||
maxWidth, | |||
this.rows.length, | |||
config | |||
); | |||
} | |||
generateNewFlow(column) { | |||
let newId; | |||
do { | |||
newId = generateRandomColorString(); | |||
} while (this.hasFlow(newId, column)); | |||
return {id: newId, color: `#${newId}`}; | |||
} | |||
hasFlow(id, column) { | |||
// We want to find the flow with the current ID | |||
// Possible flows are those in the currentFlows | |||
// Or flows in previousFlows[column-2:...] | |||
for ( | |||
let idx = column - 2 < 0 ? 0 : column - 2; | |||
idx < this.previousFlows.length; | |||
idx++ | |||
) { | |||
if (this.previousFlows[idx] && this.previousFlows[idx].id === id) { | |||
return true; | |||
} | |||
} | |||
for (let idx = 0; idx < this.currentFlows.length; idx++) { | |||
if (this.currentFlows[idx] && this.currentFlows[idx].id === id) { | |||
return true; | |||
} | |||
} | |||
return false; | |||
} | |||
takePreviousFlow(column) { | |||
if (column < this.previousFlows.length && this.previousFlows[column]) { | |||
const flow = this.previousFlows[column]; | |||
this.previousFlows[column] = null; | |||
return flow; | |||
} | |||
return this.generateNewFlow(column); | |||
} | |||
draw() { | |||
if (this.rows.length === 0) { | |||
return; | |||
} | |||
this.currentFlows = new Array(this.rows[0].length); | |||
// Generate flows for the first row - I do not believe that this can contain '_', '-', '.' | |||
for (let column = 0; column < this.rows[0].length; column++) { | |||
if (this.rows[0][column] === ' ') { | |||
continue; | |||
} | |||
this.currentFlows[column] = this.generateNewFlow(column); | |||
} | |||
// Draw the first row | |||
for (let column = 0; column < this.rows[0].length; column++) { | |||
const symbol = this.rows[0][column]; | |||
const color = this.currentFlows[column] ? this.currentFlows[column].color : ''; | |||
this.gitGraphCanvas.drawSymbol(symbol, column, 0, color); | |||
} | |||
for (let row = 1; row < this.rows.length; row++) { | |||
// Done previous row - step up the row | |||
const currentRow = this.rows[row]; | |||
const previousRow = this.rows[row - 1]; | |||
this.previousFlows = this.currentFlows; | |||
this.currentFlows = new Array(currentRow.length); | |||
// Set flows for this row | |||
for (let column = 0; column < currentRow.length; column++) { | |||
column = this.setFlowFor(column, currentRow, previousRow); | |||
} | |||
// Draw this row | |||
for (let column = 0; column < currentRow.length; column++) { | |||
const symbol = currentRow[column]; | |||
const color = this.currentFlows[column] ? this.currentFlows[column].color : ''; | |||
this.gitGraphCanvas.drawSymbol(symbol, column, row, color); | |||
} | |||
} | |||
} | |||
setFlowFor(column, currentRow, previousRow) { | |||
const symbol = currentRow[column]; | |||
switch (symbol) { | |||
case '|': | |||
case '*': | |||
return this.setUpFlow(column, currentRow, previousRow); | |||
case '/': | |||
return this.setOutFlow(column, currentRow, previousRow); | |||
case '\\': | |||
return this.setInFlow(column, currentRow, previousRow); | |||
case '_': | |||
return this.setRightFlow(column, currentRow, previousRow); | |||
case '-': | |||
return this.setLeftFlow(column, currentRow, previousRow); | |||
case ' ': | |||
// In space no one can hear you flow ... (?) | |||
return column; | |||
default: | |||
// Unexpected so let's generate a new flow and wait for bug-reports | |||
this.currentFlows[column] = this.generateNewFlow(column); | |||
return column; | |||
} | |||
} | |||
// setUpFlow handles '|' or '*' - returns the last column that was set | |||
// generally we prefer to take the left most flow from the previous row | |||
setUpFlow(column, currentRow, previousRow) { | |||
// If ' |/' or ' |_' | |||
// '/|' '/|' -> Take the '|' flow directly beneath us | |||
if ( | |||
column + 1 < currentRow.length && | |||
(currentRow[column + 1] === '/' || currentRow[column + 1] === '_') && | |||
column < previousRow.length && | |||
(previousRow[column] === '|' || previousRow[column] === '*') && | |||
previousRow[column - 1] === '/' | |||
) { | |||
this.currentFlows[column] = this.takePreviousFlow(column); | |||
return column; | |||
} | |||
// If ' |/' or ' |_' | |||
// '/ ' '/ ' -> Take the '/' flow from the preceding column | |||
if ( | |||
column + 1 < currentRow.length && | |||
(currentRow[column + 1] === '/' || currentRow[column + 1] === '_') && | |||
column - 1 < previousRow.length && | |||
previousRow[column - 1] === '/' | |||
) { | |||
this.currentFlows[column] = this.takePreviousFlow(column - 1); | |||
return column; | |||
} | |||
// If ' |' | |||
// '/' -> Take the '/' flow - (we always prefer the left-most flow) | |||
if ( | |||
column > 0 && | |||
column - 1 < previousRow.length && | |||
previousRow[column - 1] === '/' | |||
) { | |||
this.currentFlows[column] = this.takePreviousFlow(column - 1); | |||
return column; | |||
} | |||
// If '|' OR '|' take the '|' flow | |||
// '|' '*' | |||
if ( | |||
column < previousRow.length && | |||
(previousRow[column] === '|' || previousRow[column] === '*') | |||
) { | |||
this.currentFlows[column] = this.takePreviousFlow(column); | |||
return column; | |||
} | |||
// If '| ' keep the '\' flow | |||
// ' \' | |||
if (column + 1 < previousRow.length && previousRow[column + 1] === '\\') { | |||
this.currentFlows[column] = this.takePreviousFlow(column + 1); | |||
return column; | |||
} | |||
// Otherwise just create a new flow - probably this is an error... | |||
this.currentFlows[column] = this.generateNewFlow(column); | |||
return column; | |||
} | |||
// setOutFlow handles '/' - returns the last column that was set | |||
// generally we prefer to take the left most flow from the previous row | |||
setOutFlow(column, currentRow, previousRow) { | |||
// If '_/' -> keep the '_' flow | |||
if (column > 0 && currentRow[column - 1] === '_') { | |||
this.currentFlows[column] = this.currentFlows[column - 1]; | |||
return column; | |||
} | |||
// If '_|/' -> keep the '_' flow | |||
if ( | |||
column > 1 && | |||
(currentRow[column - 1] === '|' || currentRow[column - 1] === '*') && | |||
currentRow[column - 2] === '_' | |||
) { | |||
this.currentFlows[column] = this.currentFlows[column - 2]; | |||
return column; | |||
} | |||
// If '|/' | |||
// '/' -> take the '/' flow (if it is still available) | |||
if ( | |||
column > 1 && | |||
currentRow[column - 1] === '|' && | |||
column - 2 < previousRow.length && | |||
previousRow[column - 2] === '/' | |||
) { | |||
this.currentFlows[column] = this.takePreviousFlow(column - 2); | |||
return column; | |||
} | |||
// If ' /' | |||
// '/' -> take the '/' flow, but transform the symbol to '|' due to our spacing | |||
// This should only happen if there are 3 '/' - in a row so we don't need to be cleverer here | |||
if ( | |||
column > 0 && | |||
currentRow[column - 1] === ' ' && | |||
column - 1 < previousRow.length && | |||
previousRow[column - 1] === '/' | |||
) { | |||
this.currentFlows[column] = this.takePreviousFlow(column - 1); | |||
currentRow[column] = '|'; | |||
return column; | |||
} | |||
// If ' /' | |||
// '|' -> take the '|' flow | |||
if ( | |||
column > 0 && | |||
currentRow[column - 1] === ' ' && | |||
column - 1 < previousRow.length && | |||
(previousRow[column - 1] === '|' || previousRow[column - 1] === '*') | |||
) { | |||
this.currentFlows[column] = this.takePreviousFlow(column - 1); | |||
return column; | |||
} | |||
// If '/' <- Not sure this ever happens... but take the '\' flow | |||
// '\' | |||
if (column < previousRow.length && previousRow[column] === '\\') { | |||
this.currentFlows[column] = this.takePreviousFlow(column); | |||
return column; | |||
} | |||
// Otherwise just generate a new flow and wait for bug-reports... | |||
this.currentFlows[column] = this.generateNewFlow(column); | |||
return column; | |||
} | |||
// setInFlow handles '\' - returns the last column that was set | |||
// generally we prefer to take the left most flow from the previous row | |||
setInFlow(column, currentRow, previousRow) { | |||
// If '\?' | |||
// '/?' -> take the '/' flow | |||
if (column < previousRow.length && previousRow[column] === '/') { | |||
this.currentFlows[column] = this.takePreviousFlow(column); | |||
return column; | |||
} | |||
// If '\?' | |||
// ' \' -> take the '\' flow and reassign to '|' | |||
// This should only happen if there are 3 '\' - in a row so we don't need to be cleverer here | |||
if (column + 1 < previousRow.length && previousRow[column + 1] === '\\') { | |||
this.currentFlows[column] = this.takePreviousFlow(column + 1); | |||
currentRow[column] = '|'; | |||
return column; | |||
} | |||
// If '\?' | |||
// ' |' -> take the '|' flow | |||
if ( | |||
column + 1 < previousRow.length && | |||
(previousRow[column + 1] === '|' || previousRow[column + 1] === '*') | |||
) { | |||
this.currentFlows[column] = this.takePreviousFlow(column + 1); | |||
return column; | |||
} | |||
// Otherwise just generate a new flow and wait for bug-reports if we're wrong... | |||
this.currentFlows[column] = this.generateNewFlow(column); | |||
return column; | |||
} | |||
// setRightFlow handles '_' - returns the last column that was set | |||
// generally we prefer to take the left most flow from the previous row | |||
setRightFlow(column, currentRow, previousRow) { | |||
// if '__' keep the '_' flow | |||
if (column > 0 && currentRow[column - 1] === '_') { | |||
this.currentFlows[column] = this.currentFlows[column - 1]; | |||
return column; | |||
} | |||
// if '_|_' -> keep the '_' flow | |||
if ( | |||
column > 1 && | |||
currentRow[column - 1] === '|' && | |||
currentRow[column - 2] === '_' | |||
) { | |||
this.currentFlows[column] = this.currentFlows[column - 2]; | |||
return column; | |||
} | |||
// if ' _' -> take the '/' flow | |||
// '/ ' | |||
if ( | |||
column > 0 && | |||
column - 1 < previousRow.length && | |||
previousRow[column - 1] === '/' | |||
) { | |||
this.currentFlows[column] = this.takePreviousFlow(column - 1); | |||
return column; | |||
} | |||
// if ' |_' | |||
// '/? ' -> take the '/' flow (this may cause generation...) | |||
// we can do this because we know that git graph | |||
// doesn't create compact graphs like: ' |_' | |||
// '//' | |||
if ( | |||
column > 1 && | |||
column - 2 < previousRow.length && | |||
previousRow[column - 2] === '/' | |||
) { | |||
this.currentFlows[column] = this.takePreviousFlow(column - 2); | |||
return column; | |||
} | |||
// There really shouldn't be another way of doing this - generate and wait for bug-reports... | |||
this.currentFlows[column] = this.generateNewFlow(column); | |||
return column; | |||
} | |||
// setLeftFlow handles '----.' - returns the last column that was set | |||
// generally we prefer to take the left most flow from the previous row that terminates this left recursion | |||
setLeftFlow(column, currentRow, previousRow) { | |||
// This is: '----------.' or the like | |||
// ' \ \ /|\' | |||
// Find the end of the '-' or nearest '/|\' in the previousRow : | |||
let originalColumn = column; | |||
let flow; | |||
for (; column < currentRow.length && currentRow[column] === '-'; column++) { | |||
if (column > 0 && column - 1 < previousRow.length && previousRow[column - 1] === '/') { | |||
flow = this.takePreviousFlow(column - 1); | |||
break; | |||
} else if (column < previousRow.length && previousRow[column] === '|') { | |||
flow = this.takePreviousFlow(column); | |||
break; | |||
} else if ( | |||
column + 1 < previousRow.length && | |||
previousRow[column + 1] === '\\' | |||
) { | |||
flow = this.takePreviousFlow(column + 1); | |||
break; | |||
} | |||
} | |||
// if we have a flow then we found a '/|\' in the previousRow | |||
if (flow) { | |||
for (; originalColumn < column + 1; originalColumn++) { | |||
this.currentFlows[originalColumn] = flow; | |||
} | |||
return column; | |||
} | |||
// If the symbol in the column is not a '.' then there's likely an error | |||
if (currentRow[column] !== '.') { | |||
// It really should end in a '.' but this one doesn't... | |||
// 1. Step back - we don't want to eat this column | |||
column--; | |||
// 2. Generate a new flow and await bug-reports... | |||
this.currentFlows[column] = this.generateNewFlow(column); | |||
// 3. Assign all of the '-' to the same flow. | |||
for (; originalColumn < column; originalColumn++) { | |||
this.currentFlows[originalColumn] = this.currentFlows[column]; | |||
} | |||
return column; | |||
} | |||
// We have a terminal '.' eg. the current row looks like '----.' | |||
// the previous row should look like one of '/|\' eg. ' \' | |||
if (column > 0 && column - 1 < previousRow.length && previousRow[column - 1] === '/') { | |||
flow = this.takePreviousFlow(column - 1); | |||
} else if (column < previousRow.length && previousRow[column] === '|') { | |||
flow = this.takePreviousFlow(column); | |||
} else if ( | |||
column + 1 < previousRow.length && | |||
previousRow[column + 1] === '\\' | |||
) { | |||
flow = this.takePreviousFlow(column + 1); | |||
export default async function initGitGraph() { | |||
const graphContainer = document.getElementById('git-graph-container'); | |||
if (!graphContainer) return; | |||
$('#flow-color-monochrome').on('click', () => { | |||
$('#flow-color-monochrome').addClass('active'); | |||
$('#flow-color-colored').removeClass('active'); | |||
$('#git-graph-container').removeClass('colored').addClass('monochrome'); | |||
const params = new URLSearchParams(window.location.search); | |||
params.set('mode', 'monochrome'); | |||
const queryString = params.toString(); | |||
if (queryString) { | |||
window.history.replaceState({}, '', `?${queryString}`); | |||
} else { | |||
// Again unexpected so let's generate and wait the bug-report | |||
flow = this.generateNewFlow(column); | |||
} | |||
// Assign all of the rest of the ----. to this flow. | |||
for (; originalColumn < column + 1; originalColumn++) { | |||
this.currentFlows[originalColumn] = flow; | |||
window.history.replaceState({}, '', window.location.pathname); | |||
} | |||
$('.pagination a').each((_, that) => { | |||
const href = $(that).attr('href'); | |||
if (!href) return; | |||
const url = new URL(href, window.location); | |||
const params = url.searchParams; | |||
params.set('mode', 'monochrome'); | |||
url.search = `?${params.toString()}`; | |||
$(that).attr('href', url.href); | |||
}); | |||
}); | |||
$('#flow-color-colored').on('click', () => { | |||
$('#flow-color-colored').addClass('active'); | |||
$('#flow-color-monochrome').removeClass('active'); | |||
$('#git-graph-container').addClass('colored').removeClass('monochrome'); | |||
$('.pagination a').each((_, that) => { | |||
const href = $(that).attr('href'); | |||
if (!href) return; | |||
const url = new URL(href, window.location); | |||
const params = url.searchParams; | |||
params.delete('mode'); | |||
url.search = `?${params.toString()}`; | |||
$(that).attr('href', url.href); | |||
}); | |||
const params = new URLSearchParams(window.location.search); | |||
params.delete('mode'); | |||
const queryString = params.toString(); | |||
if (queryString) { | |||
window.history.replaceState({}, '', `?${queryString}`); | |||
} else { | |||
window.history.replaceState({}, '', window.location.pathname); | |||
} | |||
return column; | |||
} | |||
} | |||
function generateRandomColorString() { | |||
const chars = '0123456789ABCDEF'; | |||
const stringLength = 6; | |||
let randomString = '', | |||
rnum, | |||
i; | |||
for (i = 0; i < stringLength; i++) { | |||
rnum = Math.floor(Math.random() * chars.length); | |||
randomString += chars.substring(rnum, rnum + 1); | |||
} | |||
return randomString; | |||
} | |||
export default async function initGitGraph() { | |||
const graphCanvas = document.getElementById('graph-canvas'); | |||
if (!graphCanvas || !graphCanvas.getContext) return; | |||
// Grab the raw graphList | |||
const graphList = []; | |||
$('#graph-raw-list li span.node-relation').each(function () { | |||
graphList.push($(this).text()); | |||
}); | |||
// Define some drawing parameters | |||
const config = { | |||
unitSize: 20, | |||
lineWidth: 3, | |||
nodeRadius: 4 | |||
}; | |||
const gitGraph = new GitGraph(graphCanvas, graphList, config); | |||
gitGraph.draw(); | |||
graphCanvas.closest('#git-graph-container').classList.add('in'); | |||
$('#git-graph-container').on('mouseenter', '#rev-list li', (e) => { | |||
const flow = $(e.currentTarget).data('flow'); | |||
if (flow === 0) return; | |||
$(`#flow-${flow}`).addClass('highlight'); | |||
$(e.currentTarget).addClass('hover'); | |||
$(`#rev-list li[data-flow='${flow}']`).addClass('highlight'); | |||
}); | |||
$('#git-graph-container').on('mouseleave', '#rev-list li', (e) => { | |||
const flow = $(e.currentTarget).data('flow'); | |||
if (flow === 0) return; | |||
$(`#flow-${flow}`).removeClass('highlight'); | |||
$(e.currentTarget).removeClass('hover'); | |||
$(`#rev-list li[data-flow='${flow}']`).removeClass('highlight'); | |||
}); | |||
$('#git-graph-container').on('mouseenter', '#rel-container .flow-group', (e) => { | |||
$(e.currentTarget).addClass('highlight'); | |||
const flow = $(e.currentTarget).data('flow'); | |||
$(`#rev-list li[data-flow='${flow}']`).addClass('highlight'); | |||
}); | |||
$('#git-graph-container').on('mouseleave', '#rel-container .flow-group', (e) => { | |||
$(e.currentTarget).removeClass('highlight'); | |||
const flow = $(e.currentTarget).data('flow'); | |||
$(`#rev-list li[data-flow='${flow}']`).removeClass('highlight'); | |||
}); | |||
$('#git-graph-container').on('mouseenter', '#rel-container .flow-commit', (e) => { | |||
const rev = $(e.currentTarget).data('rev'); | |||
$(`#rev-list li#commit-${rev}`).addClass('hover'); | |||
}); | |||
$('#git-graph-container').on('mouseleave', '#rel-container .flow-commit', (e) => { | |||
const rev = $(e.currentTarget).data('rev'); | |||
$(`#rev-list li#commit-${rev}`).removeClass('hover'); | |||
}); | |||
} |
@ -0,0 +1,256 @@ | |||
#git-graph-container { | |||
float: left; | |||
display: block; | |||
overflow-x: auto; | |||
width: 100%; | |||
.color-buttons { | |||
margin-right: 0; | |||
} | |||
.ui.header.dividing { | |||
padding-bottom: 10px; | |||
} | |||
li { | |||
list-style-type: none; | |||
height: 20px; | |||
line-height: 20px; | |||
white-space: nowrap; | |||
.node-relation { | |||
font-family: "Bitstream Vera Sans Mono", "Courier", monospace; | |||
} | |||
.author { | |||
color: #666666; | |||
} | |||
.time { | |||
color: #999999; | |||
font-size: 80%; | |||
} | |||
a { | |||
color: #000000; | |||
} | |||
a:hover { | |||
text-decoration: underline; | |||
} | |||
a em { | |||
color: #bb0000; | |||
border-bottom: 1px dotted #bbbbbb; | |||
text-decoration: none; | |||
font-style: normal; | |||
} | |||
} | |||
#rel-container { | |||
max-width: 30%; | |||
overflow-x: auto; | |||
float: left; | |||
} | |||
#rev-container { | |||
width: 100%; | |||
} | |||
#rev-list { | |||
margin: 0; | |||
padding: 0 5px; | |||
min-width: 95%; | |||
li.highlight, | |||
li.hover { | |||
background-color: rgba(0, 0, 0, .05); | |||
} | |||
li.highlight.hover { | |||
background-color: rgba(0, 0, 0, .1); | |||
} | |||
} | |||
#graph-raw-list { | |||
margin: 0; | |||
} | |||
&.monochrome #rel-container { | |||
.flow-group { | |||
stroke: grey; | |||
fill: grey; | |||
} | |||
.flow-group.highlight { | |||
stroke: black; | |||
fill: black; | |||
} | |||
} | |||
&:not(.monochrome) #rel-container { | |||
.flow-group { | |||
&.flow-color-16-1 { | |||
stroke: #499a37; | |||
fill: #499a37; | |||
} | |||
&.flow-color-16-2 { | |||
stroke: hsl(356, 58%, 54%); | |||
fill: #ce4751; | |||
} | |||
&.flow-color-16-3 { | |||
stroke: #8f9121; | |||
fill: #8f9121; | |||
} | |||
&.flow-color-16-4 { | |||
stroke: #ac32a6; | |||
fill: #ac32a6; | |||
} | |||
&.flow-color-16-5 { | |||
stroke: #3d27aa; | |||
fill: #3d27aa; | |||
} | |||
&.flow-color-16-6 { | |||
stroke: #c67d28; | |||
fill: #c67d28; | |||
} | |||
&.flow-color-16-7 { | |||
stroke: #4db392; | |||
fill: #4db392; | |||
} | |||
&.flow-color-16-8 { | |||
stroke: #aa4d30; | |||
fill: #aa4d30; | |||
} | |||
&.flow-color-16-9 { | |||
stroke: #2a6f84; | |||
fill: #2a6f84; | |||
} | |||
&.flow-color-16-10 { | |||
stroke: #c45327; | |||
fill: #c45327; | |||
} | |||
&.flow-color-16-11 { | |||
stroke: #3d965c; | |||
fill: #3d965c; | |||
} | |||
&.flow-color-16-12 { | |||
stroke: #792a93; | |||
fill: #792a93; | |||
} | |||
&.flow-color-16-13 { | |||
stroke: #439d73; | |||
fill: #439d73; | |||
} | |||
&.flow-color-16-14 { | |||
stroke: #103aad; | |||
fill: #103aad; | |||
} | |||
&.flow-color-16-15 { | |||
stroke: #982e85; | |||
fill: #982e85; | |||
} | |||
&.flow-color-16-0 { | |||
stroke: #7db233; | |||
fill: #7db233; | |||
} | |||
} | |||
.flow-group.highlight { | |||
&.flow-color-16-1 { | |||
stroke: #5ac144; | |||
fill: #5ac144; | |||
} | |||
&.flow-color-16-2 { | |||
stroke: #ed5a8b; | |||
fill: #ed5a8b; | |||
} | |||
&.flow-color-16-3 { | |||
stroke: #ced049; | |||
fill: #ced048; | |||
} | |||
&.flow-color-16-4 { | |||
stroke: #db61d7; | |||
fill: #db62d6; | |||
} | |||
&.flow-color-16-5 { | |||
stroke: #4e33d1; | |||
fill: #4f35d1; | |||
} | |||
&.flow-color-16-6 { | |||
stroke: #e6a151; | |||
fill: #e6a151; | |||
} | |||
&.flow-color-16-7 { | |||
stroke: #44daaa; | |||
fill: #44daaa; | |||
} | |||
&.flow-color-16-8 { | |||
stroke: #dd7a5c; | |||
fill: #dd7a5c; | |||
} | |||
&.flow-color-16-9 { | |||
stroke: #38859c; | |||
fill: #38859c; | |||
} | |||
&.flow-color-16-10 { | |||
stroke: #d95520; | |||
fill: #d95520; | |||
} | |||
&.flow-color-16-11 { | |||
stroke: #42ae68; | |||
fill: #42ae68; | |||
} | |||
&.flow-color-16-12 { | |||
stroke: #9126b5; | |||
fill: #9126b5; | |||
} | |||
&.flow-color-16-13 { | |||
stroke: #4ab080; | |||
fill: #4ab080; | |||
} | |||
&.flow-color-16-14 { | |||
stroke: #284fb8; | |||
fill: #284fb8; | |||
} | |||
&.flow-color-16-15 { | |||
stroke: #971c80; | |||
fill: #971c80; | |||
} | |||
&.flow-color-16-0 { | |||
stroke: #87ca28; | |||
fill: #87ca28; | |||
} | |||
} | |||
} | |||
} |
@ -1,15 +0,0 @@ | |||
/* This is a customized version of https://github.com/bluef/gitgraph.js/blob/master/gitgraph.css | |||
Changes include the removal of `body` and `em` styles */ | |||
#git-graph-container, #rel-container {float:left;} | |||
#rel-container {max-width:30%; overflow-x:auto;} | |||
#git-graph-container {overflow-x:auto; width:100%} | |||
#git-graph-container li {list-style-type:none;height:20px;line-height:20px; white-space:nowrap;} | |||
#git-graph-container li .node-relation {font-family:'Bitstream Vera Sans Mono', 'Courier', monospace;} | |||
#git-graph-container li .author {color:#666666;} | |||
#git-graph-container li .time {color:#999999;font-size:80%} | |||
#git-graph-container li a {color:#000000;} | |||
#git-graph-container li a:hover {text-decoration:underline;} | |||
#git-graph-container li a em {color:#BB0000;border-bottom:1px dotted #BBBBBB;text-decoration:none;font-style:normal;} | |||
#rev-container {width:100%} | |||
#rev-list {margin:0;padding:0 5px 0 5px;min-width:95%} | |||
#graph-raw-list {margin:0px;} |
@ -0,0 +1 @@ | |||
<svg viewBox="0 0 768 768"><path d="M384 627V163.5l-135 135c-36 36-57 85.5-57 136.5 0 103.19 88.8 192 192 192zm181.5-373.5C666 354 666 514.5 565.5 615 516 664.5 450 690 384 690s-132-25.5-181.5-75C102 514.5 102 354 202.5 253.5L384 72z"/></svg> |
@ -0,0 +1 @@ | |||
<svg viewBox="0 0 768 768"><path d="M559.5 384c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zm-96-127.5c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zm-159 0c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zm-96 127.5c27 0 48-21 48-48s-21-48-48-48-48 21-48 48 21 48 48 48zM384 96c159 0 288 115.5 288 256.5 0 88.5-72 159-160.5 159H456c-27 0-48 21-48 48 0 12 4.5 22.5 12 31.5s12 21 12 33c0 27-21 48-48 48-159 0-288-129-288-288S225 96 384 96z"/></svg> |