Skip to content

Commit

Permalink
Draw Arrows on SVGs (#137)
Browse files Browse the repository at this point in the history
* draw arrows in svgs

* add a test, update tests

* update svg and png assets
  • Loading branch information
jcalabro authored Nov 25, 2024
1 parent c26788f commit 03e8c09
Show file tree
Hide file tree
Showing 7 changed files with 1,224 additions and 6 deletions.
Binary file modified example.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 19 additions & 1 deletion image/black_example.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified image/example.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 19 additions & 1 deletion image/example.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
291 changes: 291 additions & 0 deletions image/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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 (
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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(`<svg>
<defs>
<marker
id="head-%d"
orient="auto"
markerWidth="3"
markerHeight="4"
refX="0"
refY="1.5">
<path d="M0,0 V3 L2,1.5 Z" fill="%s" fill-opacity="%s" />
</marker>
</defs>
<path
marker-end="url(#head-%d)"
stroke-width="%dpx"
d="M%f,%f %f,%f"
stroke="%s"
stroke-opacity="%s" />
</svg>`,
// 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 := `<svg>
<defs>
<marker
id="head-%d"
orient="auto"
markerWidth="3"
markerHeight="4"
refX="0"
refY="1.5">
<path d="M0,0 V3 L2,1.5 Z" fill="%s" fill-opacity="%s" />
</marker>
</defs>
<path
stroke-width="%dpx"
d="M%f,%f %f,%f"
stroke="%s"
stroke-opacity="%s" />
<path
marker-end="url(#head-%d)"
stroke-width="%dpx"
d="M%f,%f %f,%f"
stroke="%s"
stroke-opacity="%s" />
</svg>`

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",
Expand Down
Loading

0 comments on commit 03e8c09

Please sign in to comment.