diff --git a/example.png b/example.png index bea9a16..94dd073 100644 Binary files a/example.png and b/example.png differ diff --git a/image/black_example.svg b/image/black_example.svg index c6d710f..4360faf 100644 --- a/image/black_example.svg +++ b/image/black_example.svg @@ -531,4 +531,22 @@ a - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/image/example.png b/image/example.png index bea9a16..94dd073 100644 Binary files a/image/example.png and b/image/example.png differ diff --git a/image/example.svg b/image/example.svg index 3314905..c626dda 100644 --- a/image/example.svg +++ b/image/example.svg @@ -531,4 +531,22 @@ h - \ No newline at end of file + + + + + + + + \ No newline at end of file diff --git a/image/image.go b/image/image.go index a42a7e3..72e948a 100755 --- a/image/image.go +++ b/image/image.go @@ -30,6 +30,30 @@ func SquareColors(light, dark color.Color) func(*Encoder) { } } +// Arrow allocates and returns a new arrow with the default color +func Arrow(from, to chess.Square) arrow { + return arrow{ + from: from, + to: to, + color: color.RGBA{247, 181, 75, 1}, // orange + } +} + +// WithColor alters the color of the arrow (the default is a light orange) +func (arrow arrow) WithColor(col color.Color) arrow { + arrow.color = col + return arrow +} + +// MarkArrows is designed to be used as an optional argument +// to the SVG function. It marks an arrow between the given +// squares with the an Arrow of the given color. +func MarkArrows(arrows ...arrow) func(*encoder) { + return func(e *encoder) { + e.arrows = append(e.arrows, arrows...) + } +} + // MarkSquares is designed to be used as an optional argument // to the SVG function. It marks the given squares with the // color. A possible usage includes marking squares of the @@ -58,6 +82,14 @@ type Encoder struct { dark color.Color perspective chess.Color marks map[chess.Square]color.Color + arrows []arrow +} + +// An arrow represents the visualization of a move +type arrow struct { + from chess.Square + to chess.Square + color color.Color } // New returns an encoder that writes to the given writer. @@ -70,6 +102,7 @@ func new(w io.Writer, options []func(*Encoder)) *Encoder { dark: color.RGBA{165, 117, 81, 1}, perspective: chess.White, marks: map[chess.Square]color.Color{}, + arrows: []arrow{}, } for _, op := range options { op(e) @@ -80,8 +113,12 @@ func new(w io.Writer, options []func(*Encoder)) *Encoder { const ( sqWidth = 45 sqHeight = 45 + sqCenter = 22.5 boardWidth = 8 * sqWidth boardHeight = 8 * sqHeight + + arrowWidth = 8 + arrowOpacity = "75%" ) var ( @@ -138,6 +175,17 @@ func (e *Encoder) EncodeSVG(b *chess.Board) error { } } } + for i, arrow := range e.arrows { + var xml string + if isKnightMove(arrow.from, arrow.to) { + xml = knightArrowXML(i, arrow, e.perspective) + } else { + xml = straightArrowXML(i, arrow, e.perspective) + } + if _, err := io.WriteString(canvas.Writer, xml); err != nil { + return err + } + } canvas.End() return nil } @@ -171,6 +219,249 @@ func pieceXML(x, y int, p chess.Piece) string { return strings.Replace(svgStr, old, new, 1) } +func straightArrowXML(id int, a arrow, perspective chess.Color) string { + c := colorToHex(a.color) + x1, y1 := squareCenter(a.from, perspective) + x2, y2 := squareCenter(a.to, perspective) + + // move the start of the arrow away from the center of the square + // as well as head of the arrow to the center of its square + offset := float32(sqCenter / 1.5) + if x1 == x2 { + // horizontal line + if y1 < y2 { + y1 += offset + y2 -= offset + } else { + y1 -= offset + y2 += offset + } + } else if y1 == y2 { + // vertical line + if x1 < x2 { + x1 += offset + x2 -= offset + } else { + x1 -= offset + x2 += offset + } + } else { + // diagonal line + offset /= 2 + if x1 < x2 { + x1 += offset + x2 -= offset + } else { + x1 -= offset + x2 += offset + } + + if y1 < y2 { + y1 += offset + y2 -= offset + } else { + y1 -= offset + y2 += offset + } + } + + return fmt.Sprintf(` + + + + + + +`, + // arrow head + id, + c, + arrowOpacity, + + // line + id, + arrowWidth, + x1, + y1, + x2, + y2, + c, + arrowOpacity, + ) +} + +func knightArrowXML(id int, a arrow, perspective chess.Color) string { + c := colorToHex(a.color) + + horizontal := horizontalMoves(a.from, a.to) + vertical := verticalMoves(a.from, a.to) + + pivot := a.from + if abs(vertical) == 1 { + pivot = squareOffset(pivot, horizontal) + } else { + if vertical > 0 { + pivot = squareOffset(pivot, 16) + } else { + pivot = squareOffset(pivot, -16) + } + } + + x11, y11 := squareCenter(a.from, perspective) + x12, y12 := squareCenter(pivot, perspective) + + x21, y21 := squareCenter(pivot, perspective) + x22, y22 := squareCenter(a.to, perspective) + + // move the start and end of the first line further away from the center of the square + offset := float32(sqCenter / 1.5) + halfWidth := float32(arrowWidth / 2) + if abs(horizontal) > abs(vertical) { + if x11 < x12 { + x11 += offset + x12 += halfWidth + } else { + x11 -= offset + x12 -= halfWidth + } + } else { + if y11 < y12 { + y11 += offset + y12 += halfWidth + } else { + y11 -= offset + y12 -= halfWidth + } + } + + // move the arrow head to the center of the square + if perspective == chess.Black { + offset *= -1 + } + if abs(horizontal) > abs(vertical) { + if vertical > 0 { + y21 += halfWidth + y22 += offset + } else { + y21 -= halfWidth + y22 -= offset + } + } else { + if horizontal > 0 { + x21 -= halfWidth + x22 -= offset + } else { + x21 += halfWidth + x22 += offset + } + } + + template := ` + + + + + + + +` + + return fmt.Sprintf( + template, + + // arrow head + id, + c, + arrowOpacity, + + // first line + arrowWidth, + x11, + y11, + x12, + y12, + c, + arrowOpacity, + + // second line + id, + arrowWidth, + x21, + y21, + x22, + y22, + c, + arrowOpacity, + ) +} + +func isKnightMove(from, to chess.Square) bool { + vertical := abs(verticalMoves(from, to)) + horizontal := abs(horizontalMoves(from, to)) + return (vertical == 1 && horizontal == 2) || (vertical == 2 && horizontal == 1) +} + +func verticalMoves(from, to chess.Square) int { + fromRank := int(from) / 8 + toRank := int(to) / 8 + return toRank - fromRank +} + +func horizontalMoves(from, to chess.Square) int { + fromMod := int(from) % 8 + toMod := int(to) % 8 + return toMod - fromMod +} + +func abs(a int) int { + if a < 0 { + return -a + } + return a +} + +func squareCenter(sq chess.Square, perspective chess.Color) (float32, float32) { + if perspective == chess.White { + x := float32(sq%8)*sqWidth + sqCenter + y := boardHeight - float32(sq/8)*sqWidth - sqCenter + return x, y + } else { + x := boardWidth - float32(sq%8)*sqWidth - sqCenter + y := float32(sq/8)*sqWidth + sqCenter + return x, y + } +} + +func squareOffset(sq chess.Square, offset int) chess.Square { + return chess.Square(int(sq) + offset) +} + var ( pieceTypeMap = map[chess.PieceType]string{ chess.King: "K", diff --git a/image/image_test.go b/image/image_test.go index b442c52..0894180 100755 --- a/image/image_test.go +++ b/image/image_test.go @@ -14,8 +14,9 @@ import ( "github.com/notnil/chess/image" ) -const expectedMD5 = "08aaa6fcfde3bb900fc54bdfef3d5c81" -const expectedMD5Black = "badac5ca5cfbdea9b98a1f9988ba54bc" +const expectedMD5 = "a2ee66ca19e4c347aec41371c1ca07f8" +const expectedMD5Black = "ce4d4e033a50678898c62928b8e0a15c" +const expectedMD5KnightsAndDiagonalArrows = "9c95aa56cec67be2ceee141f259753f7" func TestSVG(t *testing.T) { // create buffer of actual svg @@ -26,7 +27,8 @@ func TestSVG(t *testing.T) { t.Error(err) } mark := image.MarkSquares(color.RGBA{255, 255, 0, 1}, chess.D2, chess.D4) - if err := image.SVG(buf, pos.Board(), mark); err != nil { + arrows := image.MarkArrows(image.Arrow(chess.D2, chess.D4)) + if err := image.SVG(buf, pos.Board(), mark, arrows); err != nil { t.Error(err) } @@ -57,8 +59,9 @@ func TestSVGFromBlack(t *testing.T) { t.Error(err) } mark := image.MarkSquares(color.RGBA{255, 255, 0, 1}, chess.D2, chess.D4) + arrows := image.MarkArrows(image.Arrow(chess.D2, chess.D4).WithColor(color.Black)) per := image.Perspective(chess.Black) - if err := image.SVG(buf, pos.Board(), mark, per); err != nil { + if err := image.SVG(buf, pos.Board(), mark, arrows, per); err != nil { t.Error(err) } @@ -79,3 +82,57 @@ func TestSVGFromBlack(t *testing.T) { t.Error(err) } } + +func TestSVGKnightsAndDiagonals(t *testing.T) { + // create buffer of actual svg + buf := bytes.NewBuffer([]byte{}) + fenStr := "rnbqkbnr/pppppppp/8/8/3P4/8/PPP1PPPP/RNBQKBNR b KQkq - 0 1" + pos := &chess.Position{} + if err := pos.UnmarshalText([]byte(fenStr)); err != nil { + t.Error(err) + } + arrows := image.MarkArrows( + // all possible knight directions + image.Arrow(chess.F6, chess.E4), + image.Arrow(chess.F6, chess.D5), + image.Arrow(chess.F6, chess.D7), + image.Arrow(chess.F6, chess.E8), + image.Arrow(chess.F6, chess.G4), + image.Arrow(chess.F6, chess.H5), + image.Arrow(chess.F6, chess.H7), + image.Arrow(chess.F6, chess.G8), + + // a couple knight moves with no overlapping arrows + image.Arrow(chess.B1, chess.D2), + image.Arrow(chess.B8, chess.C6), + + // diagonal arrows + image.Arrow(chess.C4, chess.A6), + image.Arrow(chess.C4, chess.D5), + + // anti-diagonal arrows + image.Arrow(chess.C4, chess.A2), + image.Arrow(chess.C4, chess.D3), + ) + per := image.Perspective(chess.Black) + if err := image.SVG(buf, pos.Board(), arrows, per); err != nil { + t.Error(err) + } + + // compare to expected svg + actualSVG := strings.TrimSpace(buf.String()) + actualMD5 := fmt.Sprintf("%x", md5.Sum([]byte(actualSVG))) + if actualMD5 != expectedMD5KnightsAndDiagonalArrows { + t.Errorf("expected actual md5 hash to be %s but got %s", expectedMD5KnightsAndDiagonalArrows, actualMD5) + } + + // create actual svg file for visualization + f, err := os.Create("knight_arrows_example.svg") + defer f.Close() + if err != nil { + t.Error(err) + } + if _, err := io.Copy(f, bytes.NewBufferString(actualSVG)); err != nil { + t.Error(err) + } +} diff --git a/image/knight_arrows_example.svg b/image/knight_arrows_example.svg new file mode 100644 index 0000000..b8d567f --- /dev/null +++ b/image/knight_arrows_example.svg @@ -0,0 +1,834 @@ + + + + + + + + + + + + + + + + +1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +2 + + + + + + + + + + + + + + + + + + + + + + + + + + +3 + + + + + + + + +4 + + + + + + + + + + + +5 + + + + + + + + +6 + + + + + + + + + + + +7 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +8 +h + + + + + + + + + + +g + + + + + + + + + + + +f + + + + + + + + + + + +e + + + + + + + + + + + + + + + + + + + +d + + + + + + + + + + + +c + + + + + + + + + + +b + + + + + + + + + + + + + + + + +a + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file