Render the git graph on the server (#12333)
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>
This commit is contained in:
parent
f1a42f5d5e
commit
2c1ae6c82d
19 changed files with 1666 additions and 696 deletions
|
@ -1,8 +1,5 @@
|
||||||
extends: stylelint-config-standard
|
extends: stylelint-config-standard
|
||||||
|
|
||||||
ignoreFiles:
|
|
||||||
- web_src/less/vendor/**/*
|
|
||||||
|
|
||||||
rules:
|
rules:
|
||||||
at-rule-empty-line-before: null
|
at-rule-empty-line-before: null
|
||||||
block-closing-brace-empty-line-before: null
|
block-closing-brace-empty-line-before: null
|
||||||
|
|
|
@ -16,26 +16,9 @@ import (
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GraphItem represent one commit, or one relation in timeline
|
|
||||||
type GraphItem struct {
|
|
||||||
GraphAcii string
|
|
||||||
Relation string
|
|
||||||
Branch string
|
|
||||||
Rev string
|
|
||||||
Date string
|
|
||||||
Author string
|
|
||||||
AuthorEmail string
|
|
||||||
ShortRev string
|
|
||||||
Subject string
|
|
||||||
OnlyRelation bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// GraphItems is a list of commits from all branches
|
|
||||||
type GraphItems []GraphItem
|
|
||||||
|
|
||||||
// GetCommitGraph return a list of commit (GraphItems) from all branches
|
// GetCommitGraph return a list of commit (GraphItems) from all branches
|
||||||
func GetCommitGraph(r *git.Repository, page int) (GraphItems, error) {
|
func GetCommitGraph(r *git.Repository, page int, maxAllowedColors int) (*Graph, error) {
|
||||||
format := "DATA:|%d|%H|%ad|%an|%ae|%h|%s"
|
format := "DATA:%d|%H|%ad|%an|%ae|%h|%s"
|
||||||
|
|
||||||
if page == 0 {
|
if page == 0 {
|
||||||
page = 1
|
page = 1
|
||||||
|
@ -51,7 +34,8 @@ func GetCommitGraph(r *git.Repository, page int) (GraphItems, error) {
|
||||||
"--date=iso",
|
"--date=iso",
|
||||||
fmt.Sprintf("--pretty=format:%s", format),
|
fmt.Sprintf("--pretty=format:%s", format),
|
||||||
)
|
)
|
||||||
commitGraph := make([]GraphItem, 0, 100)
|
graph := NewGraph()
|
||||||
|
|
||||||
stderr := new(strings.Builder)
|
stderr := new(strings.Builder)
|
||||||
stdoutReader, stdoutWriter, err := os.Pipe()
|
stdoutReader, stdoutWriter, err := os.Pipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -64,86 +48,56 @@ func GetCommitGraph(r *git.Repository, page int) (GraphItems, error) {
|
||||||
if err := graphCmd.RunInDirTimeoutEnvFullPipelineFunc(nil, -1, r.Path, stdoutWriter, stderr, nil, func(ctx context.Context, cancel context.CancelFunc) error {
|
if err := graphCmd.RunInDirTimeoutEnvFullPipelineFunc(nil, -1, r.Path, stdoutWriter, stderr, nil, func(ctx context.Context, cancel context.CancelFunc) error {
|
||||||
_ = stdoutWriter.Close()
|
_ = stdoutWriter.Close()
|
||||||
defer stdoutReader.Close()
|
defer stdoutReader.Close()
|
||||||
|
parser := &Parser{}
|
||||||
|
parser.firstInUse = -1
|
||||||
|
parser.maxAllowedColors = maxAllowedColors
|
||||||
|
if maxAllowedColors > 0 {
|
||||||
|
parser.availableColors = make([]int, maxAllowedColors)
|
||||||
|
for i := range parser.availableColors {
|
||||||
|
parser.availableColors[i] = i + 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parser.availableColors = []int{1, 2}
|
||||||
|
}
|
||||||
for commitsToSkip > 0 && scanner.Scan() {
|
for commitsToSkip > 0 && scanner.Scan() {
|
||||||
line := scanner.Bytes()
|
line := scanner.Bytes()
|
||||||
dataIdx := bytes.Index(line, []byte("DATA:"))
|
dataIdx := bytes.Index(line, []byte("DATA:"))
|
||||||
|
if dataIdx < 0 {
|
||||||
|
dataIdx = len(line)
|
||||||
|
}
|
||||||
starIdx := bytes.IndexByte(line, '*')
|
starIdx := bytes.IndexByte(line, '*')
|
||||||
if starIdx >= 0 && starIdx < dataIdx {
|
if starIdx >= 0 && starIdx < dataIdx {
|
||||||
commitsToSkip--
|
commitsToSkip--
|
||||||
}
|
}
|
||||||
|
parser.ParseGlyphs(line[:dataIdx])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
row := 0
|
||||||
|
|
||||||
// Skip initial non-commit lines
|
// Skip initial non-commit lines
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
if bytes.IndexByte(scanner.Bytes(), '*') >= 0 {
|
line := scanner.Bytes()
|
||||||
line := scanner.Text()
|
if bytes.IndexByte(line, '*') >= 0 {
|
||||||
graphItem, err := graphItemFromString(line, r)
|
if err := parser.AddLineToGraph(graph, row, line); err != nil {
|
||||||
if err != nil {
|
|
||||||
cancel()
|
cancel()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
commitGraph = append(commitGraph, graphItem)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
parser.ParseGlyphs(line)
|
||||||
}
|
}
|
||||||
|
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
row++
|
||||||
graphItem, err := graphItemFromString(line, r)
|
line := scanner.Bytes()
|
||||||
if err != nil {
|
if err := parser.AddLineToGraph(graph, row, line); err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
commitGraph = append(commitGraph, graphItem)
|
|
||||||
}
|
}
|
||||||
return scanner.Err()
|
return scanner.Err()
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return commitGraph, err
|
return graph, err
|
||||||
}
|
}
|
||||||
|
return graph, nil
|
||||||
return commitGraph, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func graphItemFromString(s string, r *git.Repository) (GraphItem, error) {
|
|
||||||
|
|
||||||
var ascii string
|
|
||||||
var data = "|||||||"
|
|
||||||
lines := strings.SplitN(s, "DATA:", 2)
|
|
||||||
|
|
||||||
switch len(lines) {
|
|
||||||
case 1:
|
|
||||||
ascii = lines[0]
|
|
||||||
case 2:
|
|
||||||
ascii = lines[0]
|
|
||||||
data = lines[1]
|
|
||||||
default:
|
|
||||||
return GraphItem{}, fmt.Errorf("Failed parsing grap line:%s. Expect 1 or two fields", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := strings.SplitN(data, "|", 8)
|
|
||||||
if len(rows) < 8 {
|
|
||||||
return GraphItem{}, fmt.Errorf("Failed parsing grap line:%s - Should containt 8 datafields", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* // see format in getCommitGraph()
|
|
||||||
0 Relation string
|
|
||||||
1 Branch string
|
|
||||||
2 Rev string
|
|
||||||
3 Date string
|
|
||||||
4 Author string
|
|
||||||
5 AuthorEmail string
|
|
||||||
6 ShortRev string
|
|
||||||
7 Subject string
|
|
||||||
*/
|
|
||||||
gi := GraphItem{ascii,
|
|
||||||
rows[0],
|
|
||||||
rows[1],
|
|
||||||
rows[2],
|
|
||||||
rows[3],
|
|
||||||
rows[4],
|
|
||||||
rows[5],
|
|
||||||
rows[6],
|
|
||||||
rows[7],
|
|
||||||
len(rows[2]) == 0, // no commits referred to, only relation in current line.
|
|
||||||
}
|
|
||||||
return gi, nil
|
|
||||||
}
|
}
|
||||||
|
|
186
modules/gitgraph/graph_models.go
Normal file
186
modules/gitgraph/graph_models.go
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -5,7 +5,9 @@
|
||||||
package gitgraph
|
package gitgraph
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/git"
|
"code.gitea.io/gitea/modules/git"
|
||||||
|
@ -14,40 +16,235 @@ import (
|
||||||
func BenchmarkGetCommitGraph(b *testing.B) {
|
func BenchmarkGetCommitGraph(b *testing.B) {
|
||||||
|
|
||||||
currentRepo, err := git.OpenRepository(".")
|
currentRepo, err := git.OpenRepository(".")
|
||||||
if err != nil {
|
if err != nil || currentRepo == nil {
|
||||||
b.Error("Could not open repository")
|
b.Error("Could not open repository")
|
||||||
}
|
}
|
||||||
defer currentRepo.Close()
|
defer currentRepo.Close()
|
||||||
|
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
graph, err := GetCommitGraph(currentRepo, 1)
|
graph, err := GetCommitGraph(currentRepo, 1, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.Error("Could get commit graph")
|
b.Error("Could get commit graph")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(graph) < 100 {
|
if len(graph.Commits) < 100 {
|
||||||
b.Error("Should get 100 log lines.")
|
b.Error("Should get 100 log lines.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func BenchmarkParseCommitString(b *testing.B) {
|
func BenchmarkParseCommitString(b *testing.B) {
|
||||||
testString := "* DATA:||4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|Kjell Kvinge|kjell@kvinge.biz|4e61bac|Add route for graph"
|
testString := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|Kjell Kvinge|kjell@kvinge.biz|4e61bac|Add route for graph"
|
||||||
|
|
||||||
|
parser := &Parser{}
|
||||||
|
parser.Reset()
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
graphItem, err := graphItemFromString(testString, nil)
|
parser.Reset()
|
||||||
if err != nil {
|
graph := NewGraph()
|
||||||
|
if err := parser.AddLineToGraph(graph, 0, []byte(testString)); err != nil {
|
||||||
b.Error("could not parse teststring")
|
b.Error("could not parse teststring")
|
||||||
}
|
}
|
||||||
|
if graph.Flows[1].Commits[0].Author != "Kjell Kvinge" {
|
||||||
if graphItem.Author != "Kjell Kvinge" {
|
|
||||||
b.Error("Did not get expected data")
|
b.Error("Did not get expected data")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func BenchmarkParseGlyphs(b *testing.B) {
|
||||||
|
parser := &Parser{}
|
||||||
|
parser.Reset()
|
||||||
|
tgBytes := []byte(testglyphs)
|
||||||
|
tg := tgBytes
|
||||||
|
idx := bytes.Index(tg, []byte("\n"))
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
parser.Reset()
|
||||||
|
tg = tgBytes
|
||||||
|
idx = bytes.Index(tg, []byte("\n"))
|
||||||
|
for idx > 0 {
|
||||||
|
parser.ParseGlyphs(tg[:idx])
|
||||||
|
tg = tg[idx+1:]
|
||||||
|
idx = bytes.Index(tg, []byte("\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseUnusedColors(t *testing.T) {
|
||||||
|
testcases := []struct {
|
||||||
|
availableColors []int
|
||||||
|
oldColors []int
|
||||||
|
firstInUse int // these values have to be either be correct or suggest less is
|
||||||
|
firstAvailable int // available than possibly is - i.e. you cannot say 10 is available when it
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
|
||||||
|
oldColors: []int{1, 1, 1, 1, 1},
|
||||||
|
firstAvailable: -1,
|
||||||
|
firstInUse: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
|
||||||
|
oldColors: []int{1, 2, 3, 4},
|
||||||
|
firstAvailable: 6,
|
||||||
|
firstInUse: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
availableColors: []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
|
||||||
|
oldColors: []int{6, 0, 3, 5, 3, 4, 0, 0},
|
||||||
|
firstAvailable: 6,
|
||||||
|
firstInUse: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
availableColors: []int{1, 2, 3, 4, 5, 6, 7},
|
||||||
|
oldColors: []int{6, 1, 3, 5, 3, 4, 2, 7},
|
||||||
|
firstAvailable: -1,
|
||||||
|
firstInUse: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
availableColors: []int{1, 2, 3, 4, 5, 6, 7},
|
||||||
|
oldColors: []int{6, 0, 3, 5, 3, 4, 2, 7},
|
||||||
|
firstAvailable: -1,
|
||||||
|
firstInUse: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, testcase := range testcases {
|
||||||
|
parser := &Parser{}
|
||||||
|
parser.Reset()
|
||||||
|
parser.availableColors = append([]int{}, testcase.availableColors...)
|
||||||
|
parser.oldColors = append(parser.oldColors, testcase.oldColors...)
|
||||||
|
parser.firstAvailable = testcase.firstAvailable
|
||||||
|
parser.firstInUse = testcase.firstInUse
|
||||||
|
parser.releaseUnusedColors()
|
||||||
|
|
||||||
|
if parser.firstAvailable == -1 {
|
||||||
|
// All in use
|
||||||
|
for _, color := range parser.availableColors {
|
||||||
|
found := false
|
||||||
|
for _, oldColor := range parser.oldColors {
|
||||||
|
if oldColor == color {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should be available but is not",
|
||||||
|
testcase.availableColors,
|
||||||
|
testcase.oldColors,
|
||||||
|
testcase.firstAvailable,
|
||||||
|
testcase.firstInUse,
|
||||||
|
parser.availableColors,
|
||||||
|
parser.oldColors,
|
||||||
|
parser.firstAvailable,
|
||||||
|
parser.firstInUse,
|
||||||
|
color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if parser.firstInUse != -1 {
|
||||||
|
// Some in use
|
||||||
|
for i := parser.firstInUse; i != parser.firstAvailable; i = (i + 1) % len(parser.availableColors) {
|
||||||
|
color := parser.availableColors[i]
|
||||||
|
found := false
|
||||||
|
for _, oldColor := range parser.oldColors {
|
||||||
|
if oldColor == color {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should be available but is not",
|
||||||
|
testcase.availableColors,
|
||||||
|
testcase.oldColors,
|
||||||
|
testcase.firstAvailable,
|
||||||
|
testcase.firstInUse,
|
||||||
|
parser.availableColors,
|
||||||
|
parser.oldColors,
|
||||||
|
parser.firstAvailable,
|
||||||
|
parser.firstInUse,
|
||||||
|
color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := parser.firstAvailable; i != parser.firstInUse; i = (i + 1) % len(parser.availableColors) {
|
||||||
|
color := parser.availableColors[i]
|
||||||
|
found := false
|
||||||
|
for _, oldColor := range parser.oldColors {
|
||||||
|
if oldColor == color {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should not be available but is",
|
||||||
|
testcase.availableColors,
|
||||||
|
testcase.oldColors,
|
||||||
|
testcase.firstAvailable,
|
||||||
|
testcase.firstInUse,
|
||||||
|
parser.availableColors,
|
||||||
|
parser.oldColors,
|
||||||
|
parser.firstAvailable,
|
||||||
|
parser.firstInUse,
|
||||||
|
color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// None in use
|
||||||
|
for _, color := range parser.oldColors {
|
||||||
|
if color != 0 {
|
||||||
|
t.Errorf("In testcase:\n%d\t%d\t%d %d =>\n%d\t%d\t%d %d: %d should not be available but is",
|
||||||
|
testcase.availableColors,
|
||||||
|
testcase.oldColors,
|
||||||
|
testcase.firstAvailable,
|
||||||
|
testcase.firstInUse,
|
||||||
|
parser.availableColors,
|
||||||
|
parser.oldColors,
|
||||||
|
parser.firstAvailable,
|
||||||
|
parser.firstInUse,
|
||||||
|
color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseGlyphs(t *testing.T) {
|
||||||
|
parser := &Parser{}
|
||||||
|
parser.Reset()
|
||||||
|
tgBytes := []byte(testglyphs)
|
||||||
|
tg := tgBytes
|
||||||
|
idx := bytes.Index(tg, []byte("\n"))
|
||||||
|
row := 0
|
||||||
|
for idx > 0 {
|
||||||
|
parser.ParseGlyphs(tg[:idx])
|
||||||
|
tg = tg[idx+1:]
|
||||||
|
idx = bytes.Index(tg, []byte("\n"))
|
||||||
|
if parser.flows[0] != 1 {
|
||||||
|
t.Errorf("First column flow should be 1 but was %d", parser.flows[0])
|
||||||
|
}
|
||||||
|
colorToFlow := map[int]int64{}
|
||||||
|
flowToColor := map[int64]int{}
|
||||||
|
|
||||||
|
for i, flow := range parser.flows {
|
||||||
|
if flow == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
color := parser.colors[i]
|
||||||
|
|
||||||
|
if fColor, in := flowToColor[flow]; in && fColor != color {
|
||||||
|
t.Errorf("Row %d column %d flow %d has color %d but should be %d", row, i, flow, color, fColor)
|
||||||
|
}
|
||||||
|
flowToColor[flow] = color
|
||||||
|
if cFlow, in := colorToFlow[color]; in && cFlow != flow {
|
||||||
|
t.Errorf("Row %d column %d flow %d has color %d but conflicts with flow %d", row, i, flow, color, cFlow)
|
||||||
|
}
|
||||||
|
colorToFlow[color] = flow
|
||||||
|
}
|
||||||
|
row++
|
||||||
|
}
|
||||||
|
if len(parser.availableColors) != 9 {
|
||||||
|
t.Errorf("Expected 9 colors but have %d", len(parser.availableColors))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCommitStringParsing(t *testing.T) {
|
func TestCommitStringParsing(t *testing.T) {
|
||||||
dataFirstPart := "* DATA:||4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|Author|user@mail.something|4e61bac|"
|
dataFirstPart := "* DATA:|4e61bacab44e9b4730e44a6615d04098dd3a8eaf|2016-12-20 21:10:41 +0100|Author|user@mail.something|4e61bac|"
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
shouldPass bool
|
shouldPass bool
|
||||||
testName string
|
testName string
|
||||||
|
@ -62,15 +259,460 @@ func TestCommitStringParsing(t *testing.T) {
|
||||||
|
|
||||||
t.Run(test.testName, func(t *testing.T) {
|
t.Run(test.testName, func(t *testing.T) {
|
||||||
testString := fmt.Sprintf("%s%s", dataFirstPart, test.commitMessage)
|
testString := fmt.Sprintf("%s%s", dataFirstPart, test.commitMessage)
|
||||||
graphItem, err := graphItemFromString(testString, nil)
|
idx := strings.Index(testString, "DATA:")
|
||||||
|
commit, err := NewCommit(0, 0, []byte(testString[idx+5:]))
|
||||||
if err != nil && test.shouldPass {
|
if err != nil && test.shouldPass {
|
||||||
t.Errorf("Could not parse %s", testString)
|
t.Errorf("Could not parse %s", testString)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if test.commitMessage != graphItem.Subject {
|
if test.commitMessage != commit.Subject {
|
||||||
t.Errorf("%s does not match %s", test.commitMessage, graphItem.Subject)
|
t.Errorf("%s does not match %s", test.commitMessage, commit.Subject)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var testglyphs = `*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*
|
||||||
|
|\
|
||||||
|
* |
|
||||||
|
* |
|
||||||
|
* |
|
||||||
|
* |
|
||||||
|
* |
|
||||||
|
| *
|
||||||
|
* |
|
||||||
|
| *
|
||||||
|
| |\
|
||||||
|
* | |
|
||||||
|
| | *
|
||||||
|
| | |\
|
||||||
|
* | | \
|
||||||
|
|\ \ \ \
|
||||||
|
| * | | |
|
||||||
|
| |\| | |
|
||||||
|
* | | | |
|
||||||
|
|/ / / /
|
||||||
|
| | | *
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
* | | |
|
||||||
|
* | | |
|
||||||
|
* | | |
|
||||||
|
* | | |
|
||||||
|
* | | |
|
||||||
|
|\ \ \ \
|
||||||
|
| | * | |
|
||||||
|
| | |\| |
|
||||||
|
| | | * |
|
||||||
|
| | | | *
|
||||||
|
* | | | |
|
||||||
|
* | | | |
|
||||||
|
* | | | |
|
||||||
|
* | | | |
|
||||||
|
* | | | |
|
||||||
|
|\ \ \ \ \
|
||||||
|
| * | | | |
|
||||||
|
|/| | | | |
|
||||||
|
| | |/ / /
|
||||||
|
| |/| | |
|
||||||
|
| | | | *
|
||||||
|
| * | | |
|
||||||
|
|/| | | |
|
||||||
|
| * | | |
|
||||||
|
|/| | | |
|
||||||
|
| | |/ /
|
||||||
|
| |/| |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| |\ \ \
|
||||||
|
| | * | |
|
||||||
|
| |/| | |
|
||||||
|
| | | |/
|
||||||
|
| | |/|
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| | * |
|
||||||
|
| | |\ \
|
||||||
|
| | | * |
|
||||||
|
| | |/| |
|
||||||
|
| | | * |
|
||||||
|
| | | |\ \
|
||||||
|
| | | | * |
|
||||||
|
| | | |/| |
|
||||||
|
| | * | | |
|
||||||
|
| | * | | |
|
||||||
|
| | |\ \ \ \
|
||||||
|
| | | * | | |
|
||||||
|
| | |/| | | |
|
||||||
|
| | | | | * |
|
||||||
|
| | | | |/ /
|
||||||
|
* | | | / /
|
||||||
|
|/ / / / /
|
||||||
|
* | | | |
|
||||||
|
|\ \ \ \ \
|
||||||
|
| * | | | |
|
||||||
|
|/| | | | |
|
||||||
|
| * | | | |
|
||||||
|
| * | | | |
|
||||||
|
| |\ \ \ \ \
|
||||||
|
| | | * \ \ \
|
||||||
|
| | | |\ \ \ \
|
||||||
|
| | | | * | | |
|
||||||
|
| | | |/| | | |
|
||||||
|
| | | | | |/ /
|
||||||
|
| | | | |/| |
|
||||||
|
* | | | | | |
|
||||||
|
* | | | | | |
|
||||||
|
* | | | | | |
|
||||||
|
| | | | * | |
|
||||||
|
* | | | | | |
|
||||||
|
| | * | | | |
|
||||||
|
| |/| | | | |
|
||||||
|
* | | | | | |
|
||||||
|
| |/ / / / /
|
||||||
|
|/| | | | |
|
||||||
|
| | | | * |
|
||||||
|
| | | |/ /
|
||||||
|
| | |/| |
|
||||||
|
| * | | |
|
||||||
|
| | | | *
|
||||||
|
| | * | |
|
||||||
|
| | |\ \ \
|
||||||
|
| | | * | |
|
||||||
|
| | |/| | |
|
||||||
|
| | | |/ /
|
||||||
|
| | | * |
|
||||||
|
| | * | |
|
||||||
|
| | |\ \ \
|
||||||
|
| | | * | |
|
||||||
|
| | |/| | |
|
||||||
|
| | | |/ /
|
||||||
|
| | | * |
|
||||||
|
* | | | |
|
||||||
|
|\ \ \ \ \
|
||||||
|
| * \ \ \ \
|
||||||
|
| |\ \ \ \ \
|
||||||
|
| | | |/ / /
|
||||||
|
| | |/| | |
|
||||||
|
| | | | * |
|
||||||
|
| | | | * |
|
||||||
|
* | | | | |
|
||||||
|
* | | | | |
|
||||||
|
|/ / / / /
|
||||||
|
| | | * |
|
||||||
|
* | | | |
|
||||||
|
* | | | |
|
||||||
|
* | | | |
|
||||||
|
* | | | |
|
||||||
|
|\ \ \ \ \
|
||||||
|
| * | | | |
|
||||||
|
|/| | | | |
|
||||||
|
| | * | | |
|
||||||
|
| | |\ \ \ \
|
||||||
|
| | | * | | |
|
||||||
|
| | |/| | | |
|
||||||
|
| |/| | |/ /
|
||||||
|
| | | |/| |
|
||||||
|
| | | | | *
|
||||||
|
| |_|_|_|/
|
||||||
|
|/| | | |
|
||||||
|
| | * | |
|
||||||
|
| |/ / /
|
||||||
|
* | | |
|
||||||
|
* | | |
|
||||||
|
| | * |
|
||||||
|
* | | |
|
||||||
|
* | | |
|
||||||
|
| * | |
|
||||||
|
| | * |
|
||||||
|
| * | |
|
||||||
|
* | | |
|
||||||
|
|\ \ \ \
|
||||||
|
| * | | |
|
||||||
|
|/| | | |
|
||||||
|
| |/ / /
|
||||||
|
| * | |
|
||||||
|
| |\ \ \
|
||||||
|
| | * | |
|
||||||
|
| |/| | |
|
||||||
|
| | |/ /
|
||||||
|
| | * |
|
||||||
|
| | |\ \
|
||||||
|
| | | * |
|
||||||
|
| | |/| |
|
||||||
|
* | | | |
|
||||||
|
* | | | |
|
||||||
|
|\ \ \ \ \
|
||||||
|
| * | | | |
|
||||||
|
|/| | | | |
|
||||||
|
| | * | | |
|
||||||
|
| | * | | |
|
||||||
|
| | * | | |
|
||||||
|
| |/ / / /
|
||||||
|
| * | | |
|
||||||
|
| |\ \ \ \
|
||||||
|
| | * | | |
|
||||||
|
| |/| | | |
|
||||||
|
* | | | | |
|
||||||
|
* | | | | |
|
||||||
|
* | | | | |
|
||||||
|
* | | | | |
|
||||||
|
* | | | | |
|
||||||
|
| | | | * |
|
||||||
|
* | | | | |
|
||||||
|
|\ \ \ \ \ \
|
||||||
|
| * | | | | |
|
||||||
|
|/| | | | | |
|
||||||
|
| | | | | * |
|
||||||
|
| | | | |/ /
|
||||||
|
* | | | | |
|
||||||
|
|\ \ \ \ \ \
|
||||||
|
* | | | | | |
|
||||||
|
* | | | | | |
|
||||||
|
| | | | * | |
|
||||||
|
* | | | | | |
|
||||||
|
* | | | | | |
|
||||||
|
|\ \ \ \ \ \ \
|
||||||
|
| | |_|_|/ / /
|
||||||
|
| |/| | | | |
|
||||||
|
| | | | * | |
|
||||||
|
| | | | * | |
|
||||||
|
| | | | * | |
|
||||||
|
| | | | * | |
|
||||||
|
| | | | * | |
|
||||||
|
| | | | * | |
|
||||||
|
| | | |/ / /
|
||||||
|
| | | * | |
|
||||||
|
| | | * | |
|
||||||
|
| | | * | |
|
||||||
|
| | |/| | |
|
||||||
|
| | | * | |
|
||||||
|
| | |/| | |
|
||||||
|
| | | |/ /
|
||||||
|
| | * | |
|
||||||
|
| |/| | |
|
||||||
|
| | | * |
|
||||||
|
| | |/ /
|
||||||
|
| | * |
|
||||||
|
| * | |
|
||||||
|
| |\ \ \
|
||||||
|
| * | | |
|
||||||
|
| | * | |
|
||||||
|
| |/| | |
|
||||||
|
| | |/ /
|
||||||
|
| | * |
|
||||||
|
| | |\ \
|
||||||
|
| | * | |
|
||||||
|
* | | | |
|
||||||
|
|\| | | |
|
||||||
|
| * | | |
|
||||||
|
| * | | |
|
||||||
|
| * | | |
|
||||||
|
| | * | |
|
||||||
|
| * | | |
|
||||||
|
| |\| | |
|
||||||
|
| * | | |
|
||||||
|
| | * | |
|
||||||
|
| | * | |
|
||||||
|
| * | | |
|
||||||
|
| * | | |
|
||||||
|
| * | | |
|
||||||
|
| * | | |
|
||||||
|
| * | | |
|
||||||
|
| * | | |
|
||||||
|
| * | | |
|
||||||
|
| * | | |
|
||||||
|
| | * | |
|
||||||
|
| * | | |
|
||||||
|
| * | | |
|
||||||
|
| * | | |
|
||||||
|
| * | | |
|
||||||
|
| | * | |
|
||||||
|
* | | | |
|
||||||
|
|\| | | |
|
||||||
|
| | * | |
|
||||||
|
| * | | |
|
||||||
|
| |\| | |
|
||||||
|
| | * | |
|
||||||
|
| | * | |
|
||||||
|
| | * | |
|
||||||
|
| | | * |
|
||||||
|
* | | | |
|
||||||
|
|\| | | |
|
||||||
|
| | * | |
|
||||||
|
| | |/ /
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| |\| |
|
||||||
|
* | | |
|
||||||
|
|\| | |
|
||||||
|
| | * |
|
||||||
|
| | * |
|
||||||
|
| | * |
|
||||||
|
| * | |
|
||||||
|
| | * |
|
||||||
|
| * | |
|
||||||
|
| | * |
|
||||||
|
| | * |
|
||||||
|
| | * |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
* | | |
|
||||||
|
|\| | |
|
||||||
|
| * | |
|
||||||
|
| |\| |
|
||||||
|
| | * |
|
||||||
|
| | |\ \
|
||||||
|
* | | | |
|
||||||
|
|\| | | |
|
||||||
|
| * | | |
|
||||||
|
| |\| | |
|
||||||
|
| | * | |
|
||||||
|
| | | * |
|
||||||
|
| | |/ /
|
||||||
|
* | | |
|
||||||
|
* | | |
|
||||||
|
|\| | |
|
||||||
|
| * | |
|
||||||
|
| |\| |
|
||||||
|
| | * |
|
||||||
|
| | * |
|
||||||
|
| | * |
|
||||||
|
| | | *
|
||||||
|
* | | |
|
||||||
|
|\| | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| | | *
|
||||||
|
| | | |\
|
||||||
|
* | | | |
|
||||||
|
| |_|_|/
|
||||||
|
|/| | |
|
||||||
|
| * | |
|
||||||
|
| |\| |
|
||||||
|
| | * |
|
||||||
|
| | * |
|
||||||
|
| | * |
|
||||||
|
| | * |
|
||||||
|
| | * |
|
||||||
|
| * | |
|
||||||
|
* | | |
|
||||||
|
|\| | |
|
||||||
|
| * | |
|
||||||
|
|/| | |
|
||||||
|
| |/ /
|
||||||
|
| * |
|
||||||
|
| |\ \
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
* | | |
|
||||||
|
|\| | |
|
||||||
|
| | * |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
* | | |
|
||||||
|
|\| | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| | * |
|
||||||
|
| | |\ \
|
||||||
|
| | |/ /
|
||||||
|
| |/| |
|
||||||
|
| * | |
|
||||||
|
* | | |
|
||||||
|
|\| | |
|
||||||
|
| * | |
|
||||||
|
* | | |
|
||||||
|
|\| | |
|
||||||
|
| * | |
|
||||||
|
| |\ \ \
|
||||||
|
| * | | |
|
||||||
|
| * | | |
|
||||||
|
| | | * |
|
||||||
|
| * | | |
|
||||||
|
| * | | |
|
||||||
|
| | |/ /
|
||||||
|
| |/| |
|
||||||
|
| | * |
|
||||||
|
* | | |
|
||||||
|
|\| | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| |\ \ \
|
||||||
|
* | | | |
|
||||||
|
|\| | | |
|
||||||
|
| * | | |
|
||||||
|
| * | | |
|
||||||
|
* | | | |
|
||||||
|
* | | | |
|
||||||
|
|\| | | |
|
||||||
|
| | | | *
|
||||||
|
| | | | |\
|
||||||
|
| |_|_|_|/
|
||||||
|
|/| | | |
|
||||||
|
| * | | |
|
||||||
|
* | | | |
|
||||||
|
* | | | |
|
||||||
|
|\| | | |
|
||||||
|
| * | | |
|
||||||
|
| |\ \ \ \
|
||||||
|
| | | |/ /
|
||||||
|
| | |/| |
|
||||||
|
| * | | |
|
||||||
|
| * | | |
|
||||||
|
| * | | |
|
||||||
|
| * | | |
|
||||||
|
| | * | |
|
||||||
|
| | | * |
|
||||||
|
| | |/ /
|
||||||
|
| |/| |
|
||||||
|
* | | |
|
||||||
|
|\| | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
* | | |
|
||||||
|
|\| | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
* | | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
* | | |
|
||||||
|
* | | |
|
||||||
|
* | | |
|
||||||
|
|\| | |
|
||||||
|
| * | |
|
||||||
|
* | | |
|
||||||
|
* | | |
|
||||||
|
* | | |
|
||||||
|
* | | |
|
||||||
|
| | | *
|
||||||
|
* | | |
|
||||||
|
|\| | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
| * | |
|
||||||
|
`
|
||||||
|
|
338
modules/gitgraph/parser.go
Normal file
338
modules/gitgraph/parser.go
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -99,8 +99,19 @@ func NewFuncMap() []template.FuncMap {
|
||||||
"Subtract": base.Subtract,
|
"Subtract": base.Subtract,
|
||||||
"EntryIcon": base.EntryIcon,
|
"EntryIcon": base.EntryIcon,
|
||||||
"MigrationIcon": MigrationIcon,
|
"MigrationIcon": MigrationIcon,
|
||||||
"Add": func(a, b int) int {
|
"Add": func(a ...int) int {
|
||||||
return a + b
|
sum := 0
|
||||||
|
for _, val := range a {
|
||||||
|
sum += val
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
},
|
||||||
|
"Mul": func(a ...int) int {
|
||||||
|
sum := 1
|
||||||
|
for _, val := range a {
|
||||||
|
sum *= val
|
||||||
|
}
|
||||||
|
return sum
|
||||||
},
|
},
|
||||||
"ActionIcon": ActionIcon,
|
"ActionIcon": ActionIcon,
|
||||||
"DateFmtLong": func(t time.Time) string {
|
"DateFmtLong": func(t time.Time) string {
|
||||||
|
@ -437,6 +448,20 @@ func NewTextFuncMap() []texttmpl.FuncMap {
|
||||||
}
|
}
|
||||||
return float32(n) * 100 / float32(sum)
|
return float32(n) * 100 / float32(sum)
|
||||||
},
|
},
|
||||||
|
"Add": func(a ...int) int {
|
||||||
|
sum := 0
|
||||||
|
for _, val := range a {
|
||||||
|
sum += val
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
},
|
||||||
|
"Mul": func(a ...int) int {
|
||||||
|
sum := 1
|
||||||
|
for _, val := range a {
|
||||||
|
sum *= val
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -775,6 +775,8 @@ audio_not_supported_in_browser = Your browser does not support the HTML5 'audio'
|
||||||
stored_lfs = Stored with Git LFS
|
stored_lfs = Stored with Git LFS
|
||||||
symbolic_link = Symbolic link
|
symbolic_link = Symbolic link
|
||||||
commit_graph = Commit Graph
|
commit_graph = Commit Graph
|
||||||
|
commit_graph.monochrome = Mono
|
||||||
|
commit_graph.color = Color
|
||||||
blame = Blame
|
blame = Blame
|
||||||
normal_view = Normal View
|
normal_view = Normal View
|
||||||
line = line
|
line = line
|
||||||
|
|
1
public/img/svg/material-invert-colors.svg
Normal file
1
public/img/svg/material-invert-colors.svg
Normal file
|
@ -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>
|
After Width: | Height: | Size: 319 B |
1
public/img/svg/material-palette.svg
Normal file
1
public/img/svg/material-palette.svg
Normal file
|
@ -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>
|
After Width: | Height: | Size: 540 B |
|
@ -90,6 +90,11 @@ func Commits(ctx *context.Context) {
|
||||||
func Graph(ctx *context.Context) {
|
func Graph(ctx *context.Context) {
|
||||||
ctx.Data["PageIsCommits"] = true
|
ctx.Data["PageIsCommits"] = true
|
||||||
ctx.Data["PageIsViewCode"] = true
|
ctx.Data["PageIsViewCode"] = true
|
||||||
|
mode := strings.ToLower(ctx.QueryTrim("mode"))
|
||||||
|
if mode != "monochrome" {
|
||||||
|
mode = "color"
|
||||||
|
}
|
||||||
|
ctx.Data["Mode"] = mode
|
||||||
|
|
||||||
commitsCount, err := ctx.Repo.GetCommitsCount()
|
commitsCount, err := ctx.Repo.GetCommitsCount()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -105,7 +110,7 @@ func Graph(ctx *context.Context) {
|
||||||
|
|
||||||
page := ctx.QueryInt("page")
|
page := ctx.QueryInt("page")
|
||||||
|
|
||||||
graph, err := gitgraph.GetCommitGraph(ctx.Repo.GitRepo, page)
|
graph, err := gitgraph.GetCommitGraph(ctx.Repo.GitRepo, page, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("GetCommitGraph", err)
|
ctx.ServerError("GetCommitGraph", err)
|
||||||
return
|
return
|
||||||
|
@ -116,7 +121,9 @@ func Graph(ctx *context.Context) {
|
||||||
ctx.Data["Reponame"] = ctx.Repo.Repository.Name
|
ctx.Data["Reponame"] = ctx.Repo.Repository.Name
|
||||||
ctx.Data["CommitCount"] = commitsCount
|
ctx.Data["CommitCount"] = commitsCount
|
||||||
ctx.Data["Branch"] = ctx.Repo.BranchName
|
ctx.Data["Branch"] = ctx.Repo.BranchName
|
||||||
ctx.Data["Page"] = context.NewPagination(int(allCommitsCount), setting.UI.GraphMaxCommitNum, page, 5)
|
paginator := context.NewPagination(int(allCommitsCount), setting.UI.GraphMaxCommitNum, page, 5)
|
||||||
|
paginator.AddParam(ctx, "mode", "Mode")
|
||||||
|
ctx.Data["Page"] = paginator
|
||||||
ctx.HTML(200, tplGraph)
|
ctx.HTML(200, tplGraph)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,21 +2,44 @@
|
||||||
<div class="repository commits">
|
<div class="repository commits">
|
||||||
{{template "repo/header" .}}
|
{{template "repo/header" .}}
|
||||||
<div class="ui container">
|
<div class="ui container">
|
||||||
<div id="git-graph-container" class="ui segment">
|
<div id="git-graph-container" class="ui segment{{if eq .Mode "monochrome"}} monochrome{{end}}">
|
||||||
<h1>{{.i18n.Tr "repo.commit_graph"}}</h1>
|
<h2 class="ui header dividing">{{.i18n.Tr "repo.commit_graph"}}
|
||||||
|
<div class="ui right">
|
||||||
|
<div class="ui icon buttons tiny color-buttons">
|
||||||
|
<button id="flow-color-monochrome" class="ui labelled icon button{{if eq .Mode "monochrome"}} active{{end}}" title="{{.i18n.Tr "repo.commit_graph.monochrome"}}"><span class="emoji">{{svg "material-invert-colors" 16}}</span> {{.i18n.Tr "repo.commit_graph.monochrome"}}</button>
|
||||||
|
<button id="flow-color-colored" class="ui labelled icon button{{if ne .Mode "monochrome"}} active{{end}}" title="{{.i18n.Tr "repo.commit_graph.color"}}"><span class="emoji">{{svg "material-palette" 16}}</span> {{.i18n.Tr "repo.commit_graph.color"}}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</h2>
|
||||||
|
<div class="ui dividing"></div>
|
||||||
<div id="rel-container">
|
<div id="rel-container">
|
||||||
<canvas id="graph-canvas">
|
<svg viewbox="{{Mul .Graph.MinColumn 5}} {{Mul .Graph.MinRow 10}} {{Add (Mul .Graph.Width 5) 5}} {{Mul .Graph.Height 10}}" width="{{Add (Mul .Graph.Width 10) 10}}px">
|
||||||
<ul id="graph-raw-list">
|
{{range $flowid, $flow := .Graph.Flows}}
|
||||||
{{ range .Graph }}
|
<g id="flow-{{$flow.ID}}" class="flow-group flow-color-{{$flow.ColorNumber}} flow-color-16-{{$flow.Color16}}" data-flow="{{$flow.ID}}" data-color="{{$flow.ColorNumber}}">
|
||||||
<li><span class="node-relation">{{ .GraphAcii -}}</span></li>
|
<path d="{{range $i, $glyph := $flow.Glyphs -}}
|
||||||
|
{{- if or (eq $glyph.Glyph '*') (eq $glyph.Glyph '|') -}}
|
||||||
|
M {{Add (Mul $glyph.Column 5) 5}} {{Add (Mul $glyph.Row 10) 0}} v 10 {{/* */ -}}
|
||||||
|
{{- else if eq $glyph.Glyph '/' -}}
|
||||||
|
M {{Add (Mul $glyph.Column 5) 10}} {{Add (Mul $glyph.Row 10) 0}} l -10 10 {{/* */ -}}
|
||||||
|
{{- else if eq $glyph.Glyph '\\' -}}
|
||||||
|
M {{Add (Mul $glyph.Column 5) 0}} {{Add (Mul $glyph.Row 10) 0}} l 10 10 {{/* */ -}}
|
||||||
|
{{- else if or (eq $glyph.Glyph '-') (eq $glyph.Glyph '.') -}}
|
||||||
|
M {{Add (Mul $glyph.Column 5) 0}} {{Add (Mul $glyph.Row 10) 10}} h 5 {{/* */ -}}
|
||||||
|
{{- else if eq $glyph.Glyph '_' -}}
|
||||||
|
M {{Add (Mul $glyph.Column 5) 0}} {{Add (Mul $glyph.Row 10) 10}} h 10 {{/* */ -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end}}" stroke-width="1" fill="none" id="flow-{{$flow.ID}}-path" stroke-linecap="round"/>
|
||||||
|
{{range $flow.Commits}}
|
||||||
|
<circle class="flow-commit" cx="{{Add (Mul .Column 5) 5}}" cy="{{Add (Mul .Row 10) 5}}" r="2.5" stroke="none" id="flow-commit-{{.Rev}}" data-rev="{{.Rev}}"/>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</g>
|
||||||
</canvas>
|
{{end}}
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div id="rev-container">
|
<div id="rev-container">
|
||||||
<ul id="rev-list">
|
<ul id="rev-list">
|
||||||
{{ range .Graph }}
|
{{ range .Graph.Commits }}
|
||||||
<li>
|
<li id="commit-{{.Rev}}" data-flow="{{.Flow}}">
|
||||||
{{ if .OnlyRelation }}
|
{{ if .OnlyRelation }}
|
||||||
<span />
|
<span />
|
||||||
{{ else }}
|
{{ else }}
|
||||||
|
|
|
@ -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);
|
|
||||||
} 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
export default async function initGitGraph() {
|
||||||
const graphCanvas = document.getElementById('graph-canvas');
|
const graphContainer = document.getElementById('git-graph-container');
|
||||||
if (!graphCanvas || !graphCanvas.getContext) return;
|
if (!graphContainer) return;
|
||||||
|
|
||||||
// Grab the raw graphList
|
$('#flow-color-monochrome').on('click', () => {
|
||||||
const graphList = [];
|
$('#flow-color-monochrome').addClass('active');
|
||||||
$('#graph-raw-list li span.node-relation').each(function () {
|
$('#flow-color-colored').removeClass('active');
|
||||||
graphList.push($(this).text());
|
$('#git-graph-container').removeClass('colored').addClass('monochrome');
|
||||||
});
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
params.set('mode', 'monochrome');
|
||||||
// Define some drawing parameters
|
const queryString = params.toString();
|
||||||
const config = {
|
if (queryString) {
|
||||||
unitSize: 20,
|
window.history.replaceState({}, '', `?${queryString}`);
|
||||||
lineWidth: 3,
|
} else {
|
||||||
nodeRadius: 4
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
};
|
}
|
||||||
|
$('.pagination a').each((_, that) => {
|
||||||
|
const href = $(that).attr('href');
|
||||||
const gitGraph = new GitGraph(graphCanvas, graphList, config);
|
if (!href) return;
|
||||||
gitGraph.draw();
|
const url = new URL(href, window.location);
|
||||||
graphCanvas.closest('#git-graph-container').classList.add('in');
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$('#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');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -3082,11 +3082,3 @@ tbody.commit-list {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#git-graph-container {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#git-graph-container.in {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
256
web_src/less/features/gitgraph.less
Normal file
256
web_src/less/features/gitgraph.less
Normal file
|
@ -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,5 +1,6 @@
|
||||||
@import "~font-awesome/css/font-awesome.css";
|
@import "~font-awesome/css/font-awesome.css";
|
||||||
@import "./vendor/gitGraph.css";
|
|
||||||
|
@import "./features/gitgraph.less";
|
||||||
@import "./features/animations.less";
|
@import "./features/animations.less";
|
||||||
@import "./markdown/mermaid.less";
|
@import "./markdown/mermaid.less";
|
||||||
|
|
||||||
|
|
|
@ -919,11 +919,17 @@ a.ui.basic.green.label:hover {
|
||||||
|
|
||||||
.ui.active.button:active,
|
.ui.active.button:active,
|
||||||
.ui.button:active,
|
.ui.button:active,
|
||||||
.ui.button:focus {
|
.ui.button:focus,
|
||||||
|
.ui.active.button {
|
||||||
background-color: #2e3e4e;
|
background-color: #2e3e4e;
|
||||||
color: #dbdbdb;
|
color: #dbdbdb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui.active.button:hover {
|
||||||
|
background-color: #475e75;
|
||||||
|
color: #dbdbdb;
|
||||||
|
}
|
||||||
|
|
||||||
.ui.dropdown .menu .selected.item,
|
.ui.dropdown .menu .selected.item,
|
||||||
.ui.dropdown.selected {
|
.ui.dropdown.selected {
|
||||||
color: #dbdbdb;
|
color: #dbdbdb;
|
||||||
|
@ -1921,6 +1927,45 @@ footer .container .links > * {
|
||||||
color: #2a2e3a;
|
color: #2a2e3a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#git-graph-container.monochrome #rel-container .flow-group {
|
||||||
|
stroke: dimgrey;
|
||||||
|
fill: dimgrey;
|
||||||
|
}
|
||||||
|
|
||||||
|
#git-graph-container.monochrome #rel-container .flow-group.highlight {
|
||||||
|
stroke: darkgrey;
|
||||||
|
fill: darkgrey;
|
||||||
|
}
|
||||||
|
|
||||||
|
#git-graph-container:not(.monochrome) #rel-container .flow-group {
|
||||||
|
&.flow-color-16-5 {
|
||||||
|
stroke: #5543b1;
|
||||||
|
fill: #5543b1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#git-graph-container:not(.monochrome) #rel-container .flow-group.highlight {
|
||||||
|
&.flow-color-16-5 {
|
||||||
|
stroke: #7058e6;
|
||||||
|
fill: #7058e6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#git-graph-container #rev-list li.highlight,
|
||||||
|
#git-graph-container #rev-list li.hover {
|
||||||
|
background-color: rgba(255, 255, 255, .05);
|
||||||
|
}
|
||||||
|
|
||||||
|
#git-graph-container #rev-list li.highlight.hover {
|
||||||
|
background-color: rgba(255, 255, 255, .1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#git-graph-container .ui.buttons button#flow-color-monochrome.ui.button {
|
||||||
|
border-left-color: rgb(76, 80, 92);
|
||||||
|
border-left-style: solid;
|
||||||
|
border-left-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.mermaid-chart {
|
.mermaid-chart {
|
||||||
filter: invert(84%) hue-rotate(180deg);
|
filter: invert(84%) hue-rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
15
web_src/less/vendor/gitGraph.css
vendored
15
web_src/less/vendor/gitGraph.css
vendored
|
@ -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;}
|
|
1
web_src/svg/material-invert-colors.svg
Normal file
1
web_src/svg/material-invert-colors.svg
Normal file
|
@ -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>
|
After Width: | Height: | Size: 242 B |
1
web_src/svg/material-palette.svg
Normal file
1
web_src/svg/material-palette.svg
Normal file
|
@ -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>
|
After Width: | Height: | Size: 469 B |
Loading…
Reference in a new issue