sgf_render/render/
svg.rs

1use std::collections::{HashMap, HashSet};
2use std::ops::Range;
3
4use minidom::Element;
5
6use super::options::BoardSide;
7use super::{board_label_text, BoardSideSet, GobanStyle, MoveNumberOptions, RenderOptions};
8
9use crate::errors::GobanError;
10use crate::goban::{Goban, Stone, StoneColor};
11
12pub static NAMESPACE: &str = "http://www.w3.org/2000/svg";
13
14static BOARD_MARGIN: f64 = 0.64;
15static LABEL_MARGIN: f64 = 0.8;
16static REPEATED_MOVES_MARGIN: f64 = 0.32;
17
18static FONT_FAMILY: &str = "Inter";
19static FONT_SIZE: f64 = 0.45;
20static FONT_WEIGHT: usize = 700;
21
22pub fn render(goban: &Goban, options: &RenderOptions) -> Result<Element, GobanError> {
23    let (x_range, y_range) = options.goban_range.get_ranges(goban, options)?;
24    let width = x_range.end - x_range.start;
25    let height = y_range.end - y_range.start;
26    if !options.label_sides.is_empty() && width > 25 || height > 99 {
27        return Err(GobanError::UnlabellableRange);
28    }
29    let (top_margin, right_margin, bottom_margin, left_margin) = get_margins(&options.label_sides);
30
31    let definitions = {
32        let clip_path = Element::builder("clipPath", NAMESPACE)
33            .attr("id", "board-clip")
34            .append(
35                Element::builder("rect", NAMESPACE)
36                    .attr("x", format_float(f64::from(x_range.start) - 0.5))
37                    .attr("y", format_float(f64::from(y_range.start) - 0.5))
38                    .attr("width", width.to_string())
39                    .attr("height", height.to_string())
40                    .build(),
41            )
42            .build();
43        Element::builder("defs", NAMESPACE)
44            .append(clip_path)
45            .append_all(options.style.defs()?)
46            .build()
47    };
48    let diagram_width = f64::from(width) - 1.0 + 2.0 * BOARD_MARGIN + left_margin + right_margin;
49
50    let (diagram, diagram_height) = {
51        let board = build_board(goban, options);
52        let board_view = {
53            let board_view_transform = format!(
54                "translate({}, {})",
55                format_float(BOARD_MARGIN + left_margin - f64::from(x_range.start)),
56                format_float(BOARD_MARGIN + top_margin - f64::from(y_range.start))
57            );
58            Element::builder("g", NAMESPACE)
59                .attr("id", "board-view")
60                .attr("transform", board_view_transform)
61                .append(board)
62                .build()
63        };
64
65        let scale = format_float(options.viewbox_width / diagram_width);
66        let transform = format!("scale({scale}, {scale})");
67        let mut diagram_builder = Element::builder("g", NAMESPACE)
68            .attr("id", "diagram")
69            .attr("transform", transform)
70            .append(board_view);
71
72        if !options.label_sides.is_empty() {
73            let goban_size = goban.size();
74            diagram_builder = diagram_builder.append(draw_board_labels(
75                x_range,
76                goban_size.1 - height - y_range.start + 1..goban_size.1 - y_range.start + 1,
77                options,
78            ));
79        }
80
81        let mut diagram_height =
82            f64::from(height) - 1.0 + 2.0 * BOARD_MARGIN + top_margin + bottom_margin;
83        if options.kifu_mode {
84            if let Some((element, element_height)) = draw_repeated_stones(
85                goban,
86                width,
87                diagram_height + REPEATED_MOVES_MARGIN,
88                options,
89            ) {
90                diagram_builder = diagram_builder.append(element);
91                diagram_height += element_height + REPEATED_MOVES_MARGIN * 2.0;
92            }
93        }
94
95        (diagram_builder.build(), diagram_height)
96    };
97
98    let background = Element::builder("rect", NAMESPACE)
99        .attr("fill", options.style.background_fill())
100        .attr("height", "100%")
101        .attr("width", "100%")
102        .attr("x", "0")
103        .attr("y", "0")
104        .build();
105
106    let viewbox_height = options.viewbox_width * diagram_height / diagram_width;
107    let viewbox_attr = format!(
108        "0 0 {} {}",
109        format_float(options.viewbox_width),
110        format_float(viewbox_height)
111    );
112    let svg = Element::builder("svg", NAMESPACE)
113        .attr("viewBox", viewbox_attr)
114        .attr("width", options.viewbox_width.to_string())
115        .attr("font-size", FONT_SIZE.to_string())
116        .attr("font-family", FONT_FAMILY)
117        .attr("font-weight", FONT_WEIGHT)
118        .append(definitions)
119        .append(background)
120        .append(diagram)
121        .build();
122    Ok(svg)
123}
124
125/// Draws a goban with squares of unit size.
126fn build_board(goban: &Goban, options: &RenderOptions) -> Element {
127    let mut group_builder = Element::builder("g", NAMESPACE)
128        .attr("id", "goban")
129        .attr("clip-path", "url(#board-clip)")
130        .append(build_board_lines_group(goban, options))
131        .append(build_stones_group(goban, options));
132
133    let move_numbers = get_move_numbers(goban, options);
134    let no_markup_points: HashSet<(u8, u8)> = move_numbers
135        .iter()
136        .map(|(_, stone)| (stone.x, stone.y))
137        .collect();
138    if let Some(move_number_options) = &options.move_number_options {
139        group_builder = group_builder.append(build_move_numbers_group(
140            goban,
141            options,
142            move_number_options,
143            &move_numbers,
144        ));
145    }
146    if options.draw_marks {
147        group_builder = group_builder.append(build_marks_group(goban, options, &no_markup_points));
148    }
149    if options.draw_triangles {
150        group_builder =
151            group_builder.append(build_triangles_group(goban, options, &no_markup_points));
152    }
153    if options.draw_circles {
154        group_builder =
155            group_builder.append(build_circles_group(goban, options, &no_markup_points));
156    }
157    if options.draw_squares {
158        group_builder =
159            group_builder.append(build_squares_group(goban, options, &no_markup_points));
160    }
161    if options.draw_selected {
162        group_builder =
163            group_builder.append(build_selected_group(goban, options, &no_markup_points));
164    }
165    if options.draw_dimmed {
166        group_builder = group_builder.append(build_dimmed_group(goban, options));
167    }
168    if options.draw_labels {
169        group_builder = group_builder.append(build_label_group(goban, options, &no_markup_points));
170    }
171    if options.draw_lines {
172        group_builder = group_builder.append(build_line_group(goban, options));
173    }
174    if options.draw_arrows {
175        group_builder = group_builder.append(build_arrow_group(goban, options));
176    }
177
178    group_builder.build()
179}
180
181fn build_board_lines_group(goban: &Goban, options: &RenderOptions) -> Element {
182    let mut group_builder = Element::builder("g", NAMESPACE)
183        .attr("id", "lines")
184        .attr("stroke", options.style.line_color())
185        .attr("stroke-width", format_float(options.style.line_width()))
186        .attr("stroke-linecap", "square");
187
188    // Draw lines
189    let goban_size = goban.size();
190    for x in 0..goban_size.0 as usize {
191        group_builder = group_builder.append(
192            Element::builder("line", NAMESPACE)
193                .attr("x1", x.to_string())
194                .attr("y1", "0")
195                .attr("x2", x.to_string())
196                .attr("y2", (goban_size.1 - 1).to_string()),
197        );
198    }
199    for y in 0..goban_size.1 as usize {
200        group_builder = group_builder.append(
201            Element::builder("line", NAMESPACE)
202                .attr("x1", 0.to_string())
203                .attr("y1", y.to_string())
204                .attr("x2", (goban_size.0 - 1).to_string())
205                .attr("y2", y.to_string()),
206        );
207    }
208
209    // Draw hoshi
210    let hoshi_radius = options.style.hoshi_radius();
211    let mut hoshi = Element::builder("g", NAMESPACE)
212        .attr("id", "hoshi")
213        .attr("stroke", "none")
214        .attr("fill", options.style.line_color());
215    for (x, y) in goban.hoshi_points() {
216        hoshi = hoshi.append(
217            Element::builder("circle", NAMESPACE)
218                .attr("cx", x.to_string())
219                .attr("cy", y.to_string())
220                .attr("r", format_float(hoshi_radius)),
221        );
222    }
223    group_builder.append(hoshi).build()
224}
225
226fn build_stones_group(goban: &Goban, options: &RenderOptions) -> Element {
227    let mut group_builder = Element::builder("g", NAMESPACE)
228        .attr("id", "stones")
229        .attr("stroke", "none");
230    let mut stones: Vec<Stone> = if options.kifu_mode {
231        // For each intersection draw the first numbered stone, or the last non-numbered stone.
232        let mut stones: HashMap<(u8, u8), Stone> = HashMap::new();
233        let mut numbered_stones: HashMap<(u8, u8), Stone> = HashMap::new();
234        for (n, stone) in goban.moves() {
235            if n >= options.move_number_options.unwrap().start {
236                numbered_stones.entry((stone.x, stone.y)).or_insert(stone);
237            }
238            stones.insert((stone.x, stone.y), stone);
239        }
240        for (key, stone) in numbered_stones {
241            stones.insert(key, stone);
242        }
243        stones.into_values().collect()
244    } else {
245        goban.stones().collect()
246    };
247    stones.sort_by_key(|stone| (stone.y, stone.x));
248    for stone in stones {
249        group_builder = group_builder.append(draw_stone(stone, &options.style));
250    }
251    group_builder.build()
252}
253
254fn build_move_numbers_group(
255    goban: &Goban,
256    options: &RenderOptions,
257    move_number_options: &MoveNumberOptions,
258    move_numbers: &[(u64, Stone)],
259) -> Element {
260    let mut group_builder = Element::builder("g", NAMESPACE)
261        .attr("id", "move-numbers")
262        .attr("text-anchor", "middle");
263    for (n, stone) in move_numbers {
264        let stone_color = if options.kifu_mode {
265            // In kifu mode, the first numbered stone played will be shown.
266            Some(stone.color)
267        } else {
268            // Otherwise, we can look at the board.
269            goban.stone_color(stone.x, stone.y)
270        };
271        let move_number = n + move_number_options.count_from - move_number_options.start;
272        group_builder = group_builder.append(draw_move_number(
273            stone.x,
274            stone.y,
275            move_number,
276            stone_color,
277            &options.style,
278        ));
279    }
280    group_builder.build()
281}
282
283fn get_move_numbers(goban: &Goban, options: &RenderOptions) -> Vec<(u64, Stone)> {
284    let move_number_options = match options.move_number_options {
285        Some(move_number_options) => move_number_options,
286        None => return Vec::new(),
287    };
288    let numbered_moves = goban
289        .moves()
290        .skip_while(|&(n, _)| n < move_number_options.start)
291        .take_while(|&(n, _)| move_number_options.end.map(|end| n <= end).unwrap_or(true));
292    let mut move_numbers: HashMap<(u8, u8), (u64, Stone)> = HashMap::new();
293    for (n, stone) in numbered_moves {
294        if options.kifu_mode {
295            // In Kifu mode we care about the first numbered stone played.
296            move_numbers.entry((stone.x, stone.y)).or_insert((n, stone));
297        } else {
298            // Otherwise we care about the last numbered stone played.
299            move_numbers.insert((stone.x, stone.y), (n, stone));
300        }
301    }
302    let mut move_numbers: Vec<(u64, Stone)> = move_numbers.values().copied().collect();
303    move_numbers.sort_unstable_by_key(|(n, _)| *n);
304    move_numbers
305}
306
307fn build_marks_group(
308    goban: &Goban,
309    options: &RenderOptions,
310    no_markup_points: &HashSet<(u8, u8)>,
311) -> Element {
312    let mut group_builder = Element::builder("g", NAMESPACE).attr("id", "markup-marks");
313    let mut marks: Vec<_> = goban.marks().collect();
314    marks.sort_unstable();
315    for point in marks.iter().filter(|p| !no_markup_points.contains(p)) {
316        let stone_color = goban.stone_color(point.0, point.1);
317        group_builder =
318            group_builder.append(draw_mark(point.0, point.1, stone_color, &options.style));
319    }
320    group_builder.build()
321}
322
323fn build_triangles_group(
324    goban: &Goban,
325    options: &RenderOptions,
326    no_markup_points: &HashSet<(u8, u8)>,
327) -> Element {
328    let mut group_builder = Element::builder("g", NAMESPACE).attr("id", "markup-triangles");
329    let mut triangles: Vec<_> = goban.triangles().collect();
330    triangles.sort_unstable();
331    for point in triangles.iter().filter(|p| !no_markup_points.contains(p)) {
332        let stone_color = goban.stone_color(point.0, point.1);
333        group_builder =
334            group_builder.append(draw_triangle(point.0, point.1, stone_color, &options.style));
335    }
336    group_builder.build()
337}
338
339fn build_circles_group(
340    goban: &Goban,
341    options: &RenderOptions,
342    no_markup_points: &HashSet<(u8, u8)>,
343) -> Element {
344    let mut group_builder = Element::builder("g", NAMESPACE).attr("id", "markup-circles");
345    let mut circles: Vec<_> = goban.circles().collect();
346    circles.sort_unstable();
347    for point in circles.iter().filter(|p| !no_markup_points.contains(p)) {
348        let stone_color = goban.stone_color(point.0, point.1);
349        group_builder =
350            group_builder.append(draw_circle(point.0, point.1, stone_color, &options.style));
351    }
352    group_builder.build()
353}
354
355fn build_squares_group(
356    goban: &Goban,
357    options: &RenderOptions,
358    no_markup_points: &HashSet<(u8, u8)>,
359) -> Element {
360    let mut group_builder = Element::builder("g", NAMESPACE).attr("id", "markup-squares");
361    let mut squares: Vec<_> = goban.squares().collect();
362    squares.sort_unstable();
363    for point in squares.iter().filter(|p| !no_markup_points.contains(p)) {
364        let stone_color = goban.stone_color(point.0, point.1);
365        group_builder =
366            group_builder.append(draw_square(point.0, point.1, stone_color, &options.style));
367    }
368    group_builder.build()
369}
370
371fn build_selected_group(
372    goban: &Goban,
373    options: &RenderOptions,
374    no_markup_points: &HashSet<(u8, u8)>,
375) -> Element {
376    let mut group_builder = Element::builder("g", NAMESPACE).attr("id", "markup-selected");
377    let mut selected: Vec<_> = goban.selected().collect();
378    selected.sort_unstable();
379    for point in selected.iter().filter(|p| !no_markup_points.contains(p)) {
380        let stone_color = goban.stone_color(point.0, point.1);
381        group_builder =
382            group_builder.append(draw_selected(point.0, point.1, stone_color, &options.style));
383    }
384    group_builder.build()
385}
386
387fn build_dimmed_group(goban: &Goban, _options: &RenderOptions) -> Element {
388    let mut group_builder = Element::builder("g", NAMESPACE).attr("id", "markup-dimmed");
389    let mut dimmed: Vec<_> = goban.dimmed().collect();
390    dimmed.sort_unstable();
391    for point in dimmed {
392        group_builder = group_builder.append(dim_square(point.0, point.1));
393    }
394    group_builder.build()
395}
396
397fn build_label_group(
398    goban: &Goban,
399    options: &RenderOptions,
400    no_markup_points: &HashSet<(u8, u8)>,
401) -> Element {
402    let mut group_builder = Element::builder("g", NAMESPACE).attr("id", "markup-labels");
403    let mut labels: Vec<_> = goban.labels().collect();
404    labels.sort_unstable();
405    for (point, text) in labels.iter().filter(|(p, _)| !no_markup_points.contains(p)) {
406        let stone_color = goban.stone_color(point.0, point.1);
407        group_builder = group_builder.append(draw_label(
408            point.0,
409            point.1,
410            text,
411            stone_color,
412            &options.style,
413        ));
414    }
415    group_builder.build()
416}
417
418fn build_line_group(goban: &Goban, options: &RenderOptions) -> Element {
419    let mut group_builder = Element::builder("g", NAMESPACE)
420        .attr("id", "markup-lines")
421        .attr("stroke", "black")
422        .attr("stroke-width", format_float(options.style.line_width()))
423        .attr("marker-start", "url(#linehead)")
424        .attr("marker-end", "url(#linehead)");
425    let mut lines: Vec<_> = goban.lines().collect();
426    lines.sort_unstable();
427    for (p1, p2) in lines {
428        group_builder = group_builder.append(
429            Element::builder("line", NAMESPACE)
430                .attr("x1", p1.0)
431                .attr("x2", p2.0)
432                .attr("y1", p1.1)
433                .attr("y2", p2.1),
434        );
435    }
436    group_builder.build()
437}
438
439fn build_arrow_group(goban: &Goban, options: &RenderOptions) -> Element {
440    let mut group_builder = Element::builder("g", NAMESPACE)
441        .attr("id", "markup-arrows")
442        .attr("stroke", "black")
443        .attr("stroke-width", format_float(options.style.line_width()))
444        .attr("marker-end", "url(#arrowhead)");
445    let mut arrows: Vec<_> = goban.arrows().collect();
446    arrows.sort_unstable();
447    for (p1, p2) in arrows {
448        group_builder = group_builder.append(
449            Element::builder("line", NAMESPACE)
450                .attr("x1", p1.0)
451                .attr("x2", p2.0)
452                .attr("y1", p1.1)
453                .attr("y2", p2.1),
454        );
455    }
456
457    group_builder.build()
458}
459
460/// Draw labels for the provided ranges.
461///
462/// Assumes lines are a unit apart, offset by `BOARD_MARGIN`.
463/// Respects `LABEL_MARGIN`.
464fn draw_board_labels(x_range: Range<u8>, y_range: Range<u8>, options: &RenderOptions) -> Element {
465    let (top_margin, _, _, left_margin) = get_margins(&options.label_sides);
466    let transform = format!(
467        "translate({}, {})",
468        format_float(left_margin),
469        format_float(top_margin)
470    );
471    let mut group_builder = Element::builder("g", NAMESPACE)
472        .attr("id", "board-labels")
473        .attr("fill", options.style.label_color())
474        .attr("transform", transform);
475
476    if options.label_sides.contains(BoardSide::North) {
477        let mut builder = Element::builder("g", NAMESPACE).attr("text-anchor", "middle");
478        let start = x_range.start;
479        for x in x_range.clone() {
480            builder = builder.append(
481                Element::builder("text", NAMESPACE)
482                    .attr("x", format_float(f64::from(x - start) + BOARD_MARGIN))
483                    .attr("y", "0")
484                    .append(board_label_text(x))
485                    .build(),
486            );
487        }
488        group_builder = group_builder.append(builder);
489    };
490    if options.label_sides.contains(BoardSide::West) {
491        let mut builder = Element::builder("g", NAMESPACE).attr("text-anchor", "end");
492        let end = y_range.end;
493        for y in y_range.clone() {
494            builder = builder.append(
495                Element::builder("text", NAMESPACE)
496                    .attr("x", "0")
497                    .attr("y", format_float(f64::from(end - y - 1) + BOARD_MARGIN))
498                    .attr("dy", "0.35em")
499                    .append(y.to_string())
500                    .build(),
501            );
502        }
503        group_builder = group_builder.append(builder);
504    };
505    if options.label_sides.contains(BoardSide::South) {
506        let mut builder = Element::builder("g", NAMESPACE).attr("text-anchor", "middle");
507        let start = x_range.start;
508        let y = f64::from(y_range.end - y_range.start + 1) - BOARD_MARGIN;
509        for x in x_range.clone() {
510            builder = builder.append(
511                Element::builder("text", NAMESPACE)
512                    .attr("x", format_float(f64::from(x - start) + BOARD_MARGIN))
513                    .attr("y", format_float(y))
514                    .attr("alignment-baseline", "hanging")
515                    .append(board_label_text(x))
516                    .build(),
517            );
518        }
519        group_builder = group_builder.append(builder);
520    };
521    if options.label_sides.contains(BoardSide::East) {
522        let mut builder = Element::builder("g", NAMESPACE).attr("text-anchor", "start");
523        let end = y_range.end;
524        let x = f64::from(x_range.end - x_range.start + 1) - BOARD_MARGIN;
525        for y in y_range {
526            builder = builder.append(
527                Element::builder("text", NAMESPACE)
528                    .attr("x", format_float(x))
529                    .attr("y", format_float(f64::from(end - y - 1) + BOARD_MARGIN))
530                    .attr("dy", "0.35em")
531                    .append(y.to_string())
532                    .build(),
533            );
534        }
535        group_builder = group_builder.append(builder);
536    };
537
538    group_builder.build()
539}
540
541fn draw_repeated_stones(
542    goban: &Goban,
543    width: u8,
544    diagram_height: f64,
545    options: &RenderOptions,
546) -> Option<(Element, f64)> {
547    let entry_padding = 0.25;
548    let entry_width = 2.43;
549    let entry_height = 0.4;
550    let width = f64::from(width);
551    let (_, _, _, left_margin) = get_margins(&options.label_sides);
552    let columns = ((width - 1.0 - (2.0 * entry_padding)) / entry_width).floor() as usize;
553    let x = BOARD_MARGIN
554        + left_margin
555        + entry_padding
556        + (width - 1.0 - 2.0 * entry_padding - entry_width * f64::from(columns as u32)) / 2.0;
557    let y = diagram_height + entry_padding + entry_height;
558    let mut text_builder = Element::builder("text", NAMESPACE)
559        .attr("y", format_float(y))
560        .attr("font-size", format_float(entry_height))
561        .attr("fill", options.style.line_color()); // TODO: Evaluate this choice
562    let move_number_options = options.move_number_options.unwrap();
563    let repeated_moves: Vec<(u64, u64)> = {
564        let mut repeated_moves = Vec::new();
565        let mut seen_moves: HashMap<(u8, u8), u64> = HashMap::new();
566        for (n, stone) in goban.moves() {
567            match seen_moves.entry((stone.x, stone.y)) {
568                std::collections::hash_map::Entry::Occupied(mut entry) => {
569                    if *entry.get() < move_number_options.start {
570                        entry.insert(n);
571                    }
572                    let value = *entry.get();
573                    let in_range = value >= move_number_options.start
574                        && move_number_options
575                            .end
576                            .map(|end| value <= end)
577                            .unwrap_or(true);
578                    if n > value && in_range {
579                        repeated_moves.push((
580                            n + move_number_options.count_from - move_number_options.start,
581                            value + move_number_options.count_from - move_number_options.start,
582                        ));
583                    }
584                }
585                std::collections::hash_map::Entry::Vacant(entry) => {
586                    entry.insert(n);
587                }
588            }
589        }
590        repeated_moves.sort_unstable();
591        repeated_moves
592    };
593    if repeated_moves.is_empty() {
594        return None;
595    }
596    let rows = (f64::from(repeated_moves.len() as u32) / f64::from(columns as u32)).ceil();
597    for (i, (move_num, original)) in repeated_moves.iter().enumerate() {
598        let column = f64::from((i % columns) as u32);
599        let mut tspan_builder = Element::builder("tspan", NAMESPACE)
600            .append(format!("{move_num}→{original}"))
601            .attr("x", format_float(x + entry_width * column));
602        if i % columns == 0 && i != 0 {
603            tspan_builder = tspan_builder.attr("dy", format_float(entry_height));
604        }
605        text_builder = text_builder.append(tspan_builder);
606    }
607    let rect_height = 2.0 * entry_padding + entry_height * rows + options.style.line_width() * 2.0;
608    let group = Element::builder("g", NAMESPACE)
609        .attr("id", "repeated-stones")
610        .append(
611            Element::builder("rect", NAMESPACE)
612                .attr("fill", "white")
613                .attr("stroke", options.style.line_color())
614                .attr("stroke-width", format_float(options.style.line_width()))
615                .attr("x", format_float(BOARD_MARGIN + left_margin))
616                .attr("y", format_float(diagram_height))
617                .attr("width", format_float(width - 1.0))
618                .attr("height", format_float(rect_height)),
619        )
620        .append(text_builder)
621        .build();
622
623    Some((group, rect_height))
624}
625
626fn draw_stone(stone: Stone, style: &GobanStyle) -> Element {
627    let mut circle_builder = Element::builder("circle", NAMESPACE)
628        .attr("cx", stone.x)
629        .attr("cy", stone.y)
630        .attr("r", "0.48");
631    if let Some(stroke) = style.stone_stroke(stone.color) {
632        circle_builder = circle_builder
633            .attr("stroke", stroke)
634            .attr("stroke-width", format_float(style.line_width()))
635    }
636    if let Some(fill) = style.stone_fill(stone.color) {
637        circle_builder = circle_builder.attr("fill", fill);
638    }
639    circle_builder.build()
640}
641
642fn draw_move_number(
643    x: u8,
644    y: u8,
645    n: u64,
646    color: Option<StoneColor>,
647    style: &GobanStyle,
648) -> Element {
649    // let text = svg::node::Text::new(n.to_string());
650    let text_element = Element::builder("text", NAMESPACE)
651        .attr("x", x)
652        .attr("y", y)
653        .attr("dy", "0.35em")
654        .attr("fill", style.markup_color(color))
655        .append(n.to_string());
656    let mut group_builder = Element::builder("g", NAMESPACE);
657    if color.is_none() {
658        group_builder = group_builder.append(
659            Element::builder("rect", NAMESPACE)
660                .attr("fill", style.background_fill())
661                .attr("x", format_float(f64::from(x) - 0.4))
662                .attr("y", format_float(f64::from(y) - 0.4))
663                .attr("width", "0.8")
664                .attr("height", "0.8"),
665        );
666    }
667
668    group_builder.append(text_element).build()
669}
670
671fn draw_mark(x: u8, y: u8, color: Option<StoneColor>, style: &GobanStyle) -> Element {
672    Element::builder("g", NAMESPACE)
673        .attr("stroke", style.markup_color(color))
674        .attr("stroke-width", style.markup_stroke_width().to_string())
675        .append(
676            Element::builder("line", NAMESPACE)
677                .attr("x1", format_float(f64::from(x) - 0.25))
678                .attr("x2", format_float(f64::from(x) + 0.25))
679                .attr("y1", format_float(f64::from(y) - 0.25))
680                .attr("y2", format_float(f64::from(y) + 0.25)),
681        )
682        .append(
683            Element::builder("line", NAMESPACE)
684                .attr("x1", format_float(f64::from(x) - 0.25))
685                .attr("x2", format_float(f64::from(x) + 0.25))
686                .attr("y1", format_float(f64::from(y) + 0.25))
687                .attr("y2", format_float(f64::from(y) - 0.25)),
688        )
689        .build()
690}
691
692fn draw_triangle(x: u8, y: u8, color: Option<StoneColor>, style: &GobanStyle) -> Element {
693    let triangle_radius = 0.45;
694    let points = format!(
695        "{},{} {},{} {},{}",
696        x,
697        format_float(f64::from(y) - triangle_radius),
698        format_float(f64::from(x) - 0.866 * triangle_radius),
699        format_float(f64::from(y) + 0.5 * triangle_radius),
700        format_float(f64::from(x) + 0.866 * triangle_radius),
701        format_float(f64::from(y) + 0.5 * triangle_radius),
702    );
703    Element::builder("g", NAMESPACE)
704        .attr("stroke", style.markup_color(color))
705        .attr("fill", "none")
706        .attr("stroke-width", format_float(style.line_width()))
707        .append(Element::builder("polygon", NAMESPACE).attr("points", points))
708        .build()
709}
710
711fn draw_circle(x: u8, y: u8, color: Option<StoneColor>, style: &GobanStyle) -> Element {
712    let radius = 0.25;
713    Element::builder("g", NAMESPACE)
714        .attr("stroke", style.markup_color(color))
715        .attr("fill", "none")
716        .attr("stroke-width", format_float(style.line_width()))
717        .append(
718            Element::builder("circle", NAMESPACE)
719                .attr("cx", x)
720                .attr("cy", y)
721                .attr("r", format_float(radius)),
722        )
723        .build()
724}
725
726fn draw_square(x: u8, y: u8, color: Option<StoneColor>, style: &GobanStyle) -> Element {
727    let width = 0.55;
728    Element::builder("g", NAMESPACE)
729        .attr("stroke", style.markup_color(color))
730        .attr("fill", "none")
731        .attr("stroke-width", format_float(style.line_width()))
732        .append(
733            Element::builder("rect", NAMESPACE)
734                .attr("x", format_float(f64::from(x) - 0.5 * width))
735                .attr("y", format_float(f64::from(y) - 0.5 * width))
736                .attr("width", format_float(width))
737                .attr("height", format_float(width)),
738        )
739        .build()
740}
741
742fn draw_selected(x: u8, y: u8, color: Option<StoneColor>, style: &GobanStyle) -> Element {
743    let width = 0.25;
744    Element::builder("g", NAMESPACE)
745        .attr("stroke", "none")
746        .attr("fill", style.selected_color(color))
747        .attr("stroke-width", style.line_width().to_string())
748        .append(
749            Element::builder("rect", NAMESPACE)
750                .attr("x", (f64::from(x) - 0.5 * width).to_string())
751                .attr("y", (f64::from(y) - 0.5 * width).to_string())
752                .attr("width", width.to_string())
753                .attr("height", width.to_string()),
754        )
755        .build()
756}
757
758fn dim_square(x: u8, y: u8) -> Element {
759    Element::builder("g", NAMESPACE)
760        .attr("stroke", "none")
761        .attr("fill", "black")
762        .attr("fill-opacity", "0.5")
763        .attr("shape-rendering", "crispEdges")
764        .append(
765            Element::builder("rect", NAMESPACE)
766                .attr("x", format_float(f64::from(x) - 0.5))
767                .attr("y", format_float(f64::from(y) - 0.5))
768                .attr("width", "1")
769                .attr("height", "1"),
770        )
771        .build()
772}
773
774fn draw_label(x: u8, y: u8, text: &str, color: Option<StoneColor>, style: &GobanStyle) -> Element {
775    let text = text.chars().take(2).collect::<String>();
776    let text_element = Element::builder("text", NAMESPACE)
777        .attr("x", x)
778        .attr("y", y)
779        .attr("text-anchor", "middle")
780        .attr("dy", "0.35em")
781        .attr("fill", style.markup_color(color))
782        .append(text);
783    let mut group_builder = Element::builder("g", NAMESPACE);
784    if color.is_none() {
785        group_builder = group_builder.append(
786            Element::builder("rect", NAMESPACE)
787                .attr("fill", style.background_fill())
788                .attr("x", format_float(f64::from(x) - 0.4))
789                .attr("y", format_float(f64::from(y) - 0.4))
790                .attr("width", "0.8")
791                .attr("height", "0.8"),
792        );
793    }
794
795    group_builder.append(text_element).build()
796}
797
798fn get_margins(label_sides: &BoardSideSet) -> (f64, f64, f64, f64) {
799    let top = if label_sides.contains(BoardSide::North) {
800        LABEL_MARGIN
801    } else {
802        0.0
803    };
804    let right = if label_sides.contains(BoardSide::East) {
805        LABEL_MARGIN
806    } else {
807        0.0
808    };
809    let bottom = if label_sides.contains(BoardSide::South) {
810        LABEL_MARGIN
811    } else {
812        0.0
813    };
814    let left = if label_sides.contains(BoardSide::West) {
815        LABEL_MARGIN
816    } else {
817        0.0
818    };
819    (top, right, bottom, left)
820}
821
822fn format_float(x: f64) -> String {
823    format!("{x:.4}")
824        .trim_end_matches('0')
825        .trim_end_matches('.')
826        .to_string()
827}