Migrating from HTML canvas to SVG

Cyrill JaunerCyrill Jauner
9 min read

In the last article of this series, I showed how my Battleship app is deployed. This time, I want to write about a small technical migration I did in the frontend.

To display the game board in the browser, I initially started with an HTML canvas element. A canvas element allows you to draw various shapes, text, and colors. Depending on the chosen context, WebGL is also supported. The canvas is a very powerful tool for creating complex graphic displays.

However, my requirements for the rendering framework are very basic. Essentially, I need to be able to draw squares, circles, and lines in small quantities. Since I've been working more with SVG graphics professionally lately, I decided to replace the canvas with an SVG. In my view, an SVG offers a significant advantage - All elements in the SVG are part of the DOM. This makes some things easier:

  • Registering event listeners directly on SVG elements.

  • Viewing the DOM structure of the SVG in the browser's development console.

  • Testing the created graphics.

In the following text, I will discuss what exactly changed in my frontend app with the migration. And, of course, what is good about it and what might be less good ๐Ÿ˜…

Third-Party Library for Drawing SVGs

An SVG graphic can be drawn completely without additional tools. However, I don't find that very convenient. That's why I included the library SVG.js. This library is easy to use and well-documented. In the HTML of the view, there is now an SVG element instead of the canvas. That's all that is directly defined in the HTML structure. The actual scene is then drawn in the JavaScript code.

<div class="view-content">
    <h1>Place your ships</h1>
    <div class="gap-between">
        <svg id="place-ships-svg"></svg>
        <button id="save-fleet">Save Fleet</button>
    </div>
    <p hidden class="error-text">The ship positions are invalid.</p>
</div>

Changed Drawing Logic

With the canvas, I had the logic that the scene would be completely redrawn with every change. The draw function looked like this.

function draw(): void {
    if (this.context === undefined) {
        throw 'The drawing context cannot be undefined.'
    }
    this.clearCanvas()
    this.grid.draw(this.context)
    this.ships.forEach(ship => ship.draw(this.context))
    const shoot: Shoot = new Shoot(this.board)
    this.fireLogEntries.forEach(fireLogEntry => shoot.draw(this.context, fireLogEntry.coordinates))
}

First, the content of the canvas is completely cleared with clearCanvas. Then, all elements of the scene are redrawn in the correct order: first the grid, then the ships, and finally the players' shots. I don't have any issues with this function; it's just typical logic for drawing a scene on the canvas.

However, I chose a different approach with SVG. Instead of redrawing the scene each time, I create the elements for the grid and ships only at the beginning with init and add an element for each move to represent the shot with update. This way, the elements in the SVG remain, resulting in fewer repaints in the browser.

function init(svg: Svg, fleet: FieldPosition[][]): void {
    this.svg = svg
    this.ships = fleet.map(fieldPositions => this.createShipFromFieldPositions(fieldPositions))
    this.gridRenderer.render(this.svg)

    this.ships.forEach(ship => ship.createSvgElement(this.svg))
    this.svgShootGroup = svg.group()
}

function update(fireLogEntries: FireLogEntry[]) {
    this.fireLogEntries = fireLogEntries
    this.shootRenderer.createShootElement(this.svgShootGroup, this.board,
        this.fireLogEntries.map(fireLogEntry => fireLogEntry))
}

To illustrate this, I recorded both versions in the browser. In the animation, the areas that need to be redrawn are highlighted in green. This is a feature from the development console of Chromium browsers called "Paint flashing." The first GIF shows the old canvas element. As soon as one of the ships is moved, the entire canvas flashes green.

In the next GIF, the same board is drawn with the updated logic in SVG. By the way, there's also a bit of CSS, so the webpage looks a bit nicer ๐Ÿ˜‰. When moving a ship, only that ship needs to be redrawn. The rest of the scene remains unchanged.

Whether the changed drawing logic affects performance, I honestly can't say. In the scenes, very few elements are drawn, so there's no noticeable difference between the old and new logic. However, if you're interested in the differences between canvas and SVG, you can find a good article here.

The new persistent elements in SVG have also had other impacts on the app, especially with mouse events.

Everything new with the mouse events

When handling mouse events, I almost completely rewrote the code during the migration to SVG. Previously, I had event listeners registered on the canvas element. With a click on the canvas, I only had an X and a Y coordinate. I didn't know which field or ship was clicked. With the SVG elements, I could register event listeners directly on the objects in the game.

Let's take a closer look at the differences for a specific view. In the PlaceShipsView, players place their ships on the game board. The ships start at a position set by the app. Players can then move the ships using drag-and-drop. In both versions, a mouseDragging state is used. If this value is true, the current mouse position is used to move a ship.

In the code for the canvas element, a mouseDown event first requires finding the clicked ship. The ship objects have a function isClicked(mousePosition) for this purpose. During a mouseMove event, the selected ship is moved, provided a ship was clicked beforehand. Once the player releases the mouse button (mouseUp), the drag-and-drop ends. The last event handler includes the functionality for rotating a ship if it wasn't moved. After each of these actions, the canvas needs to be redrawn. As seen before, the entire scene is always redrawn.

private onMouseDown = (event: MouseEvent): void => {
    this.mouseDragStart = { x: event.offsetX, y: event.offsetY }
    this.mouseDragging = true

    const mousePosition = convertToFieldPosition(event.offsetX, event.offsetY, this.board.columnSizeInPixels)
    this.clickedShip = this.ships.find(ship => ship.isClicked(mousePosition))
}

private onMouseMove = (event: MouseEvent): void => {
    if (!this.mouseDragging) {
        return
    }
    const mousePosition = convertToFieldPosition(event.offsetX, event.offsetY, this.board.columnSizeInPixels)

    this.clickedShip?.move({ x: mousePosition.x, y: mousePosition.y })
    this.draw()
}

private onMouseUp = (event: MouseEvent): void => {
    this.mouseDragging = false
    if (this.mouseDragStart?.x === event.offsetX && this.mouseDragStart.y === event.offsetY) {
        this.clickedShip?.rotate()
        this.draw()
    }
    this.clickedShip = undefined
}

With the new SVG code, I can skip searching for the clicked ship. An event listener for mouseDown is registered on each ship object. Additionally, the scene is no longer redrawn when moving and rotating the ships. There are new functions, move and rotate, that only change the clicked ship objects. However, I now need to perform an additional conversion of the mouse coordinates. The function convertToSvgCoordinates returns the corresponding point in the SVG coordinate system for the client coordinates. Conveniently, SVG.js offers a point function for this.

this.ships.forEach((ship) => {
    ship.shipSvg?.mousedown((event: MouseEvent) => {
        const p = convertToSvgCoordinates(svg, event.clientX, event.clientY)
        this.mouseDragStart = { x: p.x, y: p.y }
        this.mouseDragging = true
        this.clickedShip = ship
    })
})

svg.mousemove((event: MouseEvent) => {
    if (!this.mouseDragging) {
        return
    }
    const p = convertToSvgCoordinates(svg, event.clientX, event.clientY)
    const mousePosition = convertToFieldPosition(p, this.board.columnSizeInPixels)
    this.clickedShip?.move({ x: mousePosition.x * this.board.columnSizeInPixels, y: mousePosition.y * this.board.columnSizeInPixels })
})

svg.mouseup((event: MouseEvent) => {
    this.mouseDragging = false
    const p = convertToSvgCoordinates(svg, event.clientX, event.clientY)
    if (this.mouseDragStart?.x === p.x && this.mouseDragStart.y === p.y) {
        this.clickedShip?.rotate()
    }
    this.clickedShip = undefined
})

svg.mouseleave(() => {
    this.mouseDragging = false
    this.clickedShip = undefined
})

Let's take a look at the new functions in the ship object. Moving and rotating the ships wasn't actually that simple. In the old canvas code, the ships were always redrawn when their position or orientation changed. That was pretty simple code. In the new code, it's a bit more complex. Let's first look at the move function.

First, the new position of the ship's center is calculated. Since the ships are within a grid, they can't be moved between the fields. Therefore, the new position must be a valid field position. Then, the center function moves the ship to the new position. It's interesting to note the distinction made if the ship has been rotated from its initial orientation.

function move(targetField: FieldPosition): void {
    this.startField = targetField
    const nextShipCenterPosition: FieldPosition = this.calculateNextShipCenterPosition(targetField)
    const ship: G = valueIfPresentOrError(this.shipSvg)
    if (this.rotated) {
        // If the ship is rotated the ship has to be rotated back first before moving.
        ship.rotate(-this.ROTATION_ANGLE_DEGREES)
            .center(nextShipCenterPosition.x, nextShipCenterPosition.y)
            .rotate(this.ROTATION_ANGLE_DEGREES)
    } else {
        ship.center(nextShipCenterPosition.x, nextShipCenterPosition.y)
    }
}

Caution with the transformations

Why is this distinction necessary?

In the following GIF, you can see the behavior when the rotation is not specifically handled. The ship is first rotated and then moved. However, the direction of the ship's movement is incorrect. The mouse moves downward, and the ship moves to the left.

I tried to put together an example here to analyze the behavior. We are looking at a ship with a length of 3.

Let's assume the initial coordinates of the ship are (5,4)(5,6)(5,7). So, the ship is oriented vertically. We perform two transformations. First, a rotation, then the ship should be moved to the mouse position (5,6). With the "incorrect" calculation, the ship shifts to the left after the rotation when the mouse is moved downward. However, the correct behavior would be for the middle part of the ship to be at the mouse position.

Internally, SVG.js uses matrices to calculate transformations. I'm trying to replicate the calculation here. The initial middle position of the ship is (5,5), and the position vector is described as follows. Note: We directly use homogeneous coordinates to calculate all transformations with multiplications.

Let's assume the rotation around the center of the ship is achieved through three steps.

  • Move to the origin

  • Rotate 90 degrees counterclockwise

  • Move back to the position before the rotation

For these transformations, we need three matrices.

We can use the resulting matrix to calculate the new positions of the ship's sections. The middle section should not change its position. For the section at (5,6), we expect the new position to be (4,5).

After the rotation, the ship should be moved down by one square. The matrix for this is as follows.

If we now directly call the center function, the error occurs. The transformations are executed in the order of the function calls. If the rotation happens first, the shift also occurs in the rotated coordinate system. The result is the incorrect vector (3,5,1).

The correct way is to first rotate the ship back and then shift it. This changes the order of the matrices and thus the result ๐Ÿค“

View of the SVG elements in the DOM

After the complicated last chapter, I want to discuss a simpler topic. Let's take a look at the DOM structure. At the beginning of the article, I already mentioned that all SVG elements are part of the DOM. For me, this is one of the main advantages of SVG graphics. This makes it possible to check in the HTML file which elements have been created. Additionally, you can select and modify elements using the browser's developer tools. Especially when debugging a problem, the DOM structure is extremely helpful. You might find yourself searching for an element and then realize it was drawn outside the viewbox.

Conclusion

Migrating from HTML canvas to SVG in the Battleship app has proven to be a beneficial decision, aligning with the app's basic rendering needs while offering enhanced functionality. The transition to SVG has simplified event handling, improved the ability to inspect and debug through the DOM, and optimized the drawing logic by reducing unnecessary repaints. Although the transformation logic required careful handling, the advantages of SVG, such as direct manipulation of elements and integration with the DOM, have outweighed the challenges. For those interested in exploring the project further, the GitHub repository is available for review and collaboration.

GitHub project link

1
Subscribe to my newsletter

Read articles from Cyrill Jauner directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Cyrill Jauner
Cyrill Jauner