Initial commit:

Added framework to generate DOT and png files of character map chart
from data folder.
Added all figures in the 1-2 chapters.
Added font file SimSum for render chart
This commit is contained in:
2024-09-02 04:59:36 +00:00
commit 8caf0ac3aa
36 changed files with 1236 additions and 0 deletions
+225
View File
@@ -0,0 +1,225 @@
package batch
import (
"fmt"
"os"
"path/filepath"
"strings"
"xyj-figures/pkg/entity"
"xyj-figures/pkg/relationship"
findfont "github.com/flopp/go-findfont"
graphviz "github.com/goccy/go-graphviz"
"github.com/goccy/go-graphviz/cgraph"
"github.com/golang/freetype/truetype"
log "github.com/sirupsen/logrus"
"golang.org/x/image/font"
)
const (
CLUSTER_PREFIX = "cluster_"
)
func LoadFolder(path string) ([]entity.Entity, []relationship.Relationship, error) {
es := []entity.Entity{}
rs := []relationship.Relationship{}
errs := []string{}
// Triverse all files in path and load all entities and relationships
err := filepath.Walk(path, func(subpath string, info os.FileInfo, err error) error {
if err != nil {
errs = append(errs, err.Error())
return nil
}
if !info.IsDir() {
e, err := entity.Load(subpath)
if err != nil {
errs = append(errs, err.Error())
return nil
}
es = append(es, *e)
rs = append(rs, e.Details.(entity.EntityDetails).GetRelationships()...)
}
return nil
})
if len(errs) > 0 {
return es, rs, fmt.Errorf("multiple errors: %v", strings.Join(errs, "; "))
}
return es, rs, err
}
// DistillData takes loaded data and do all needed optimizations and extract only needed info for maps
func DistillData(es []entity.Entity, rs []relationship.Relationship) ([]entity.Entity, []relationship.Relationship, error) {
// Create a set of all entities, including characters and every members
// TODO: Duplicated name
nodes := map[string]entity.Entity{}
edges := map[string]relationship.Relationship{}
for _, e := range es {
switch e.Type {
case entity.EntityTypeCharacter:
if val, ok := nodes[e.Details.(entity.Character).Name]; ok {
if err := val.Merge(e); err != nil {
log.Warnf("failed to merge: %v", err)
}
continue
}
nodes[e.Details.(entity.Character).Name] = e
case entity.EntityTypeGroup:
nodes[e.Details.(entity.Group).Name] = e
for _, m := range e.Details.(entity.Group).Members {
nodes[m] = entity.Entity{
Schema: "v1",
Type: entity.EntityTypeCharacter,
Details: entity.Character{
Name: m,
},
}
}
}
}
for _, r := range rs {
key1, key2 := r.GetKey()
val1, ok1 := edges[key1]
val2, ok2 := edges[key2]
// Both keys don't exist
if !ok1 && !ok2 {
edges[key1] = r
continue
}
// Logs all unexpected edges
if ok1 && !r.IsSame(val1) {
log.Warningf("Found ambigious edges, existing: %+v, new: %+v", val1, r)
}
if ok2 && !r.IsSame(val2) {
log.Warningf("Found ambigious edges, existing: %+v, new: %+v", val2, r)
}
}
retns := make([]entity.Entity, 0, len(nodes))
for _, n := range nodes {
retns = append(retns, n)
}
retes := make([]relationship.Relationship, 0, len(edges))
for _, v := range edges {
retes = append(retes, v)
}
return retns, retes, nil
}
// Save distilled info into DOT type files
func Save(path string, ents []entity.Entity, rels []relationship.Relationship) error {
g := graphviz.New()
g.SetLayout(graphviz.TWOPI)
useSystemFont(g, "SimSum.ttc")
rootgraph, err := g.Graph()
rootgraph.SafeSet("fontname", "NSimSun", "Times-Roman")
if err != nil {
return fmt.Errorf("failed to create graph: %v", err)
}
defer func() {
if err := rootgraph.Close(); err != nil {
log.Errorf("failed to close graph: %v", err)
}
g.Close()
}()
nodesPtr := map[string]*cgraph.Node{}
subGraphPtr := map[string]*cgraph.Graph{}
nodeGraphMap := map[string]string{}
for _, e := range ents {
if e.Type == entity.EntityTypeGroup {
subGraph := rootgraph.SubGraph(CLUSTER_PREFIX+e.Details.(entity.Group).Name, 1)
subGraph.SetLabel(e.Details.(entity.Group).Name)
// subGraph.SafeSet("fontname", "NSimSun", "Times-Roman")
subGraphPtr[e.Details.(entity.Group).Name] = subGraph
// Record all members
for _, m := range e.Details.(entity.Group).Members {
nodeGraphMap[m] = e.Details.(entity.Group).Name
}
}
}
fmt.Println(subGraphPtr)
fmt.Println(nodeGraphMap)
for _, e := range ents {
if e.Type == entity.EntityTypeCharacter {
log.Debugf("entity: %+v", e)
if group, ok := nodeGraphMap[e.Details.(entity.Character).Name]; ok {
ptr, ok := subGraphPtr[group]
if !ok {
log.Debugf("can't find sub graph %s", group)
continue
}
if ptr == nil {
log.Debugf("sub graph %s is nil", group)
continue
}
n, err := ptr.CreateNode(e.Details.(entity.Character).Name)
if err != nil {
log.Errorf("failed to create node in sub graph %s: %v", group, err)
continue
}
nodesPtr[e.Details.(entity.Character).Name] = n
continue
}
node, err := rootgraph.CreateNode(e.Details.(entity.Character).Name)
if err != nil {
log.Errorf("failed to create node: %v", err)
continue
}
nodesPtr[e.Details.(entity.Character).Name] = node
continue
}
}
fmt.Println(nodesPtr)
for _, r := range rels {
key, _ := r.GetKey()
srcPtr := nodesPtr[r.Src]
dstPtr := nodesPtr[r.Dst]
fmt.Printf("src: %s, dst: %s\n", r.Src, r.Dst)
e, err := rootgraph.CreateEdge(key, srcPtr, dstPtr)
if err != nil {
log.Errorf("failed to create edge: %v", err)
continue
}
e.SetLabel(r.Description)
e.SafeSet("fontname", "NSimSun", "Times-Roman")
e.SafeSet("fontsize", "12", "10")
if r.Bidirection {
e.SetDir(cgraph.NoneDir)
}
}
if err := g.RenderFilename(rootgraph, "dot", path); err != nil {
log.Fatal(err)
}
if err := g.RenderFilename(rootgraph, graphviz.PNG, path+".png"); err != nil {
return fmt.Errorf("failed to save graph to disk: %v", err)
}
return nil
}
func useSystemFont(g *graphviz.Graphviz, name string) {
fontPath, err := findfont.Find(name)
if err != nil {
panic(err)
}
fontData, err := os.ReadFile(fontPath)
if err != nil {
panic(err)
}
ft, err := truetype.Parse(fontData)
if err != nil {
panic(err)
}
g.SetFontFace(func(size float64) (font.Face, error) {
opt := &truetype.Options{
Size: size,
}
return truetype.NewFace(ft, opt), nil
})
}
+103
View File
@@ -0,0 +1,103 @@
package batch_test
import (
"os"
"path/filepath"
"reflect"
"testing"
"xyj-figures/pkg/batch"
"xyj-figures/pkg/entity"
"xyj-figures/pkg/relationship"
"github.com/go-test/deep"
)
const (
DataFolder = "../../data"
)
func TestLoadFolder(t *testing.T) {
tcs := []struct {
name string
filelist []string
want struct {
entities []entity.Entity
relationships []relationship.Relationship
errors error
}
wanterr bool
}{
{
name: "test1",
filelist: []string{
"character/孙悟空.yaml",
},
want: struct {
entities []entity.Entity
relationships []relationship.Relationship
errors error
}{
entities: []entity.Entity{
{
Schema: "v1",
Type: entity.EntityTypeCharacter,
Details: entity.Character{
Name: "孙悟空",
OtherNames: []string{"孙行者", "者行孙"},
Relationships: []relationship.Relationship{
{
Src: "孙悟空",
Dst: "唐僧",
Description: "师徒",
Bidirection: true,
},
},
Description: "孙悟空是明朝中后期畅销小说《西游记》当中个第一主角,是从石头里向蹦出来个猢狲。",
Links: []string{
"https://zh.wikipedia.org/wiki/%E5%AD%99%E6%82%9F%E7%A9%BA",
},
},
},
},
relationships: []relationship.Relationship{
{
Src: "孙悟空",
Dst: "唐僧",
Description: "师徒",
Bidirection: true,
},
},
},
wanterr: false,
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
tmpDir := t.TempDir()
for _, fn := range tc.filelist {
f, err := os.ReadFile(filepath.Join(DataFolder, fn))
if err != nil {
t.Fatalf("failed to copy file %s: %v", fn, err)
}
dstPath := filepath.Join(tmpDir, fn)
if err := os.MkdirAll(filepath.Dir(dstPath), os.ModePerm); err != nil {
t.Fatalf("failed to copy file %s: %v", fn, err)
}
if err := os.WriteFile(dstPath, f, 0644); err != nil {
t.Fatalf("failed to copy file %s: %v", fn, err)
}
}
egot, rgot, errgot := batch.LoadFolder(tmpDir)
if (!tc.wanterr && errgot != nil) || (tc.wanterr && errgot == nil) {
t.Errorf("error not expected, wanterr: %v got: %+v", tc.wanterr, errgot)
}
if !reflect.DeepEqual(egot, tc.want.entities) {
t.Errorf("entities diff: %+v", deep.Equal(egot, tc.want.entities))
}
if !reflect.DeepEqual(rgot, tc.want.relationships) {
t.Errorf("relationship diff: %+v", deep.Equal(rgot, tc.want.relationships))
}
})
}
}
+17
View File
@@ -0,0 +1,17 @@
package entity
import (
r "xyj-figures/pkg/relationship"
)
type Character struct {
Name string `yaml:"name"`
OtherNames []string `yaml:"other_names,flow"`
Relationships []r.Relationship `yaml:"relationships,flow"`
Description string `yaml:"description"`
Links []string `yaml:"links,flow"`
}
func (c Character) GetRelationships() []r.Relationship {
return c.Relationships
}
+94
View File
@@ -0,0 +1,94 @@
package entity
import (
"fmt"
"os"
r "xyj-figures/pkg/relationship"
yaml "gopkg.in/yaml.v3"
)
type Entity struct {
Schema string `yaml:"schema,flow"`
Type string `yaml:"type,flow"`
Details interface{} `yaml:"details,flow"`
}
type EntityType string
const (
EntityTypeCharacter = "character"
EntityTypeGroup = "group"
)
type EntityDetails interface {
GetRelationships() []r.Relationship
}
func NewEntity() (*Entity, error) {
return &Entity{Schema: "v1"}, nil
}
func Load(path string) (*Entity, error) {
f, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to open file: %v", err)
}
e, _ := NewEntity()
if err = yaml.Unmarshal(f, &e); err != nil {
return nil, fmt.Errorf("failed to unmarshal file: %v", err)
}
switch e.Type {
case EntityTypeCharacter:
tmp, err := yaml.Marshal(e.Details)
if err != nil {
return nil, fmt.Errorf("failed to marshal filed %s: %v", EntityTypeCharacter, err)
}
c := Character{}
if err = yaml.Unmarshal(tmp, &c); err != nil {
return nil, fmt.Errorf("failed to unmarshal filed %s: %v", EntityTypeCharacter, err)
}
e.Details = c
case EntityTypeGroup:
tmp, err := yaml.Marshal(e.Details)
if err != nil {
return nil, fmt.Errorf("failed to marshal filed %s: %v", EntityTypeGroup, err)
}
g := Group{}
if err = yaml.Unmarshal(tmp, &g); err != nil {
return nil, fmt.Errorf("failed to unmarshal filed %s: %v", EntityTypeGroup, err)
}
e.Details = g
}
return e, nil
}
func (e Entity) Merge(other Entity) error {
if (e.Schema != other.Schema) || (e.Type != other.Type) {
return fmt.Errorf("unmatch merge between %v and %v", e, other)
}
switch e.Type {
case EntityTypeCharacter:
if e.Details.(Character).Name != other.Details.(Character).Name {
return fmt.Errorf("unmatch merge between %v and %v: names unmatch", e, other)
}
e.Details = Character{
Name: e.Details.(Character).Name,
OtherNames: append(e.Details.(Character).OtherNames, other.Details.(Character).OtherNames...),
Relationships: append(e.Details.(Character).Relationships, other.Details.(Character).Relationships...),
Description: fmt.Sprintf("%s%s", e.Details.(Character).Description, other.Details.(Character).Description),
Links: append(e.Details.(Character).Links, other.Details.(Character).Links...),
}
case EntityTypeGroup:
e.Details = Group{
Name: e.Details.(Group).Name,
OtherNames: append(e.Details.(Group).OtherNames, other.Details.(Group).OtherNames...),
Members: append(e.Details.(Group).Members, other.Details.(Group).Members...),
Relationships: append(e.Details.(Group).Relationships, other.Details.(Group).Relationships...),
Description: fmt.Sprintf("%s%s", e.Details.(Group).Description, other.Details.(Group).Description),
Links: append(e.Details.(Group).Links, other.Details.(Group).Links...),
}
}
return nil
}
+123
View File
@@ -0,0 +1,123 @@
package entity_test
import (
"log"
"os"
"path/filepath"
"reflect"
"testing"
"xyj-figures/pkg/entity"
"xyj-figures/pkg/relationship"
"github.com/go-test/deep"
)
func TestLoad(t *testing.T) {
tmpDir := os.TempDir()
tcs := []struct {
name string
payload string
want entity.Entity
wanterr bool
err error
}{
{
name: "Valid Character",
payload: `schema: v1
type: character
details:
name: 孙悟空
other_names:
- 孙行者
- 者行孙
relationships:
- source: 孙悟空
destination: 唐三藏
description: 师徒
bidirection: true
description: 孙悟空是明朝中后期畅销小说《西游记》当中个第一主角,是从石头里向蹦出来个猢狲。
links:
- https://zh.wikipedia.org/wiki/%E5%AD%99%E6%82%9F%E7%A9%BA
`,
want: entity.Entity{
Schema: "v1",
Type: entity.EntityTypeCharacter,
Details: entity.Character{
Name: "孙悟空",
OtherNames: []string{"孙行者", "者行孙"},
Relationships: []relationship.Relationship{
{
Src: "孙悟空",
Dst: "唐三藏",
Description: "师徒",
Bidirection: true,
},
},
Description: "孙悟空是明朝中后期畅销小说《西游记》当中个第一主角,是从石头里向蹦出来个猢狲。",
Links: []string{
"https://zh.wikipedia.org/wiki/%E5%AD%99%E6%82%9F%E7%A9%BA",
},
},
},
wanterr: false,
},
{
name: "Valid Group",
payload: `schema: v1
type: group
details:
name: 唐僧师徒
other_names:
- 取经团队
members:
- 孙悟空
- 猪八戒
- 沙僧
- 唐僧
- 白龙马
links:
- https://zh.wikipedia.org/wiki/%E8%A5%BF%E6%B8%B8%E8%AE%B0%E8%A7%92%E8%89%B2%E5%88%97%E8%A1%A8#%E5%94%90%E5%83%A7%E5%B8%AB%E5%BE%92
`,
want: entity.Entity{
Schema: "v1",
Type: entity.EntityTypeGroup,
Details: entity.Group{
Name: "唐僧师徒",
OtherNames: []string{"取经团队"},
Members: []string{"孙悟空", "猪八戒", "沙僧", "唐僧", "白龙马"},
Links: []string{
"https://zh.wikipedia.org/wiki/%E8%A5%BF%E6%B8%B8%E8%AE%B0%E8%A7%92%E8%89%B2%E5%88%97%E8%A1%A8#%E5%94%90%E5%83%A7%E5%B8%AB%E5%BE%92",
},
},
},
wanterr: false,
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
// Prepare data
tmpFile, err := os.Create(filepath.Join(tmpDir, tc.name))
if err != nil {
t.Fatalf("Failed to create temporary file: %v", err)
}
defer tmpFile.Close()
if _, err = tmpFile.Write([]byte(tc.payload)); err != nil {
t.Fatalf("Failed to write to temporary file: %v", err)
}
// Call the function and assert the results
e, err := entity.Load(tmpFile.Name())
if err != nil && !tc.wanterr {
t.Errorf("Unexpected error: %v", err)
}
if err == nil && tc.wanterr {
t.Errorf("expected error but not get")
}
// Sanity Check
if !reflect.DeepEqual(*e, tc.want) {
log.Fatalf("Load got %+v want %+v, diff %+v", *e, tc.want, deep.Equal(*e, tc.want))
}
})
}
}
+16
View File
@@ -0,0 +1,16 @@
package entity
import r "xyj-figures/pkg/relationship"
type Group struct {
Name string
OtherNames []string `yaml:"other_names,flow"`
Members []string `yaml:"members,flow"`
Relationships []r.Relationship `yaml:"relationships,flow"`
Description string `yaml:"description"`
Links []string `yaml:"links,flow"`
}
func (g Group) GetRelationships() []r.Relationship {
return g.Relationships
}
+24
View File
@@ -0,0 +1,24 @@
package relationship
import "fmt"
type Relationship struct {
Src string `yaml:"source"`
Dst string `yaml:"destination"`
Description string `yaml:"description"`
Bidirection bool `yaml:"bidirection"`
}
func (r Relationship) IsSame(other Relationship) bool {
if r.Bidirection {
if r.Description != other.Description {
return false
}
return (r.Src == other.Src && r.Dst == other.Dst) || (r.Src == other.Dst && r.Dst == other.Src)
}
return r.Src == other.Src && r.Dst == other.Dst && r.Description == other.Description
}
func (r Relationship) GetKey() (string, string) {
return fmt.Sprintf("%s-%s", r.Src, r.Dst), fmt.Sprintf("%s-%s", r.Dst, r.Src)
}
+120
View File
@@ -0,0 +1,120 @@
package relationship_test
import (
"testing"
r "xyj-figures/pkg/relationship"
"github.com/stretchr/testify/assert"
)
func TestRelationship_IsSame(t *testing.T) {
tests := []struct {
name string
r r.Relationship
other r.Relationship
expected bool
}{
{
name: "Bidirectional relationships with same source and destination",
r: r.Relationship{
Src: "A",
Dst: "B",
Description: "friend",
Bidirection: true,
},
other: r.Relationship{
Src: "A",
Dst: "B",
Description: "friend",
Bidirection: true,
},
expected: true,
},
{
name: "Bidirectional relationships with swapped source and destination",
r: r.Relationship{
Src: "A",
Dst: "B",
Description: "friend",
Bidirection: true,
},
other: r.Relationship{
Src: "B",
Dst: "A",
Description: "friend",
Bidirection: true,
},
expected: true,
},
{
name: "Bidirectional relationships with different descriptions",
r: r.Relationship{
Src: "A",
Dst: "B",
Description: "friend",
Bidirection: true,
},
other: r.Relationship{
Src: "A",
Dst: "B",
Description: "enemy",
Bidirection: true,
},
expected: false,
},
{
name: "Unidirectional relationships with same source and destination",
r: r.Relationship{
Src: "A",
Dst: "B",
Description: "friend",
Bidirection: false,
},
other: r.Relationship{
Src: "A",
Dst: "B",
Description: "friend",
Bidirection: false,
},
expected: true,
},
{
name: "Unidirectional relationships with swapped source and destination",
r: r.Relationship{
Src: "A",
Dst: "B",
Description: "friend",
Bidirection: false,
},
other: r.Relationship{
Src: "B",
Dst: "A",
Description: "friend",
Bidirection: false,
},
expected: false,
},
{
name: "Unidirectional relationships with different descriptions",
r: r.Relationship{
Src: "A",
Dst: "B",
Description: "friend",
Bidirection: false,
},
other: r.Relationship{
Src: "A",
Dst: "B",
Description: "enemy",
Bidirection: false,
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, tt.r.IsSame(tt.other))
})
}
}