Render chess board from FEN

Hi there,
I did a thing, for rendering chessboards and drawing some arrows and circles on it. I find it useful for studying theory, analyzing games (e.g. you can highlight all your glorious blunders, or show what were the crucial ideas in the position this way). I hope someone will find it useful, it was funny to do it.

function parse_fen(fen)
    local pieces = {}
    local rows = {}
    local i = 1
    local row_start = 1
    
    while i <= #fen do
        local char = string.sub(fen, i, i)
        if char == "/" then
            table.insert(rows, string.sub(fen, row_start, i - 1))
            row_start = i + 1
        end
        i = i + 1
    end
    table.insert(rows, string.sub(fen, row_start, i))
    
    for j = 1, 8 do
        local current_row = {}
        local row = rows[j]
        local k = 1
        while k <= #row do
            local char = string.sub(row, k, k)
            if char >= "1" and char <= "8" then
                local empty_squares = tonumber(char)
                for _ = 1, empty_squares do
                    table.insert(current_row, "")
                end
            else
                table.insert(current_row, char)
            end
            k = k + 1
        end
        pieces[j] = current_row
    end
    
    return pieces
end
function parse_squares(fields, flip)
    local parsed_squares = {}
    for _, field in ipairs(fields) do
        local file, rank = field:match("(%a)(%d)")
        if file and rank then
            local x = string.byte(file:upper()) - string.byte("A") + 1
            local y = tonumber(rank)
            
            if flip then
                x = 9 - x
                y = 9 - y
            end
            
            table.insert(parsed_squares, {x, y})
        end
    end
    return parsed_squares
end
function parse_arrows(arrows, flip)
    local parsed_arrows = {}
    for _, arrow in ipairs(arrows) do
        local start_file, start_rank, end_file, end_rank = arrow:match("(%a)(%d)-(%a)(%d)")
        if start_file and start_rank and end_file and end_rank then
            local start_x = string.byte(start_file:upper()) - string.byte("A") + 1
            local start_y = tonumber(start_rank)
            local end_x = string.byte(end_file:upper()) - string.byte("A") + 1
            local end_y = tonumber(end_rank)
            
            if flip then
                start_x = 9 - start_x
                start_y = 9 - start_y
                end_x = 9 - end_x
                end_y = 9 - end_y
            end
            
            table.insert(parsed_arrows, {start_x, start_y, end_x, end_y})
        end
    end
    return parsed_arrows
end
function generate_chessboard(fen, side, arrows, circles)
    if fen == "start" then
        fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
    end

    local pieces = parse_fen(fen)
    local flip = side == "black"
    local parsed_arrows = parse_arrows(arrows or {}, flip)
    local parsed_circles = parse_squares(circles or {}, flip)
    
    local html = "<div style='display: flex; flex-direction: column; align-items: center; position: relative;'>"
    
    -- Board container
    html = html .. "<div style='display: grid; grid-template-columns: repeat(8, 1fr) auto; grid-template-rows: repeat(8, 1fr) auto; width: 50vmin; height: 50vmin; border: 2px solid black; position: relative;'>"
    
    local files = {"A", "B", "C", "D", "E", "F", "G", "H"}
    local ranks = {8, 7, 6, 5, 4, 3, 2, 1}
    
    if flip then
        files = {"H", "G", "F", "E", "D", "C", "B", "A"}
        ranks = {1, 2, 3, 4, 5, 6, 7, 8}
    end
    
    local piece_urls = {
        P = "https://upload.wikimedia.org/wikipedia/commons/4/45/Chess_plt45.svg",
        R = "https://upload.wikimedia.org/wikipedia/commons/7/72/Chess_rlt45.svg",
        N = "https://upload.wikimedia.org/wikipedia/commons/7/70/Chess_nlt45.svg",
        B = "https://upload.wikimedia.org/wikipedia/commons/b/b1/Chess_blt45.svg",
        Q = "https://upload.wikimedia.org/wikipedia/commons/1/15/Chess_qlt45.svg",
        K = "https://upload.wikimedia.org/wikipedia/commons/4/42/Chess_klt45.svg",
        p = "https://upload.wikimedia.org/wikipedia/commons/c/c7/Chess_pdt45.svg",
        r = "https://upload.wikimedia.org/wikipedia/commons/f/ff/Chess_rdt45.svg",
        n = "https://upload.wikimedia.org/wikipedia/commons/e/ef/Chess_ndt45.svg",
        b = "https://upload.wikimedia.org/wikipedia/commons/9/98/Chess_bdt45.svg",
        q = "https://upload.wikimedia.org/wikipedia/commons/4/47/Chess_qdt45.svg",
        k = "https://upload.wikimedia.org/wikipedia/commons/f/f0/Chess_kdt45.svg"
    }
    
    for i = 1, 8 do
        local row = ranks[i]
        for j = 1, 8 do
            local col = flip and (9 - j) or j
            local isDark = ((row + col) % 2 == 1)
            local bgColor = isDark and "#6d4c41" or "#ffebcd"
            local piece = pieces[9 - row][col]
            local pieceHtml = ""
            if piece and piece ~= "" then
                pieceHtml = "<img src='" .. piece_urls[piece] .. "' style='width: 80%; height: 80%; object-fit: contain;'>"
            end
            html = html
                .. "<div style='display: flex; align-items: center; justify-content: center;"
                .. "background:" .. bgColor .. ";"
                .. "border: 1px solid black;"
                .. "font-size: 4vmin; line-height: 1;'>"
                .. pieceHtml
                .. "</div>"
        end
        -- Rank labels on the right with equal spacing to file labels
        html = html .. "<div style='display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 2vmin; margin-left: 0.5vmin;'>" .. row .. "</div>"
    end
    
    -- File labels at the bottom
    for _, file in ipairs(files) do
        html = html .. "<div style='display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 2vmin; margin-top: 0.5vmin;'>" .. file .. "</div>"
    end
    
    html = html .. "</div>" -- End board grid
    
  
    html = html .. "<div style='position: absolute; width: 48.25vmin; height: 46.75vmin; margin-left: -1.75vmin; margin-top: 0.25vmin; align-items: center; display: flex; justify-content: center'>"
    html = html .. "<svg viewBox='0 0 8 8' preserveAspectRatio='none' style='width: 100%; height: 100%;'>"
    
    for _, arrow in ipairs(parsed_arrows) do
        -- In the SVG viewBox, each square is 1 unit.
        -- The center of a square at column X and rank R is at (X - 0.5, (8 - R) + 0.5).
        local start_x = arrow[1] - 0.5
        local start_y = (8 - arrow[2]) + 0.5
        local end_x   = arrow[3] - 0.5
        local end_y   = (8 - arrow[4]) + 0.5
        html = html .. "<line x1='" .. start_x .. "' y1='" .. start_y .. "' x2='" .. end_x .. "' y2='" .. end_y .. "' stroke='rgba(0, 128, 0, 0.5)' stroke-width='0.1' stroke-linecap='round' marker-end='url(#arrowhead)' />"
    end

    if parsed_circles then
        for _, circle in ipairs(parsed_circles) do
            local cx = circle[1] - 0.5
            local cy = (8 - circle[2]) + 0.5
            html = html .. "<circle cx='" .. cx .. "' cy='" .. cy .. "' r='0.4' fill='none' stroke='rgba(0, 128, 0, 0.5)' stroke-width='0.1' />"
        end
    end
  
    html = html .. [[
    <defs>
      <marker id="arrowhead" markerWidth="10" markerHeight="7" refX="4" refY="3.5"
              orient="auto" markerUnits="strokeWidth">
        <polygon points="0 0, 5 3.5, 0 7" fill="rgba(0, 128, 0, 0.5)" />
      </marker>
    </defs>
    ]]
      
    html = html .. "</svg>"
    html = html .. "</div>" -- End arrow container

    html = html .. "</div>" -- End board container
    html = html .. "</div>" -- End main container

    return {
        html = html,
        display = "block",
        cssClasses = {"square-chessboard"}
    }
end

Example usage: ${generate_chessboard("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1", "black", {"h1-g1", "h6-g6", "e2-d4", "a5-a8"}, {"a5", "h4", "e1", "b8"})}


Cheers

8 Likes