Published

Responsive Hexagonal Grids

How the (q, r) coordinate system and linear transformations are used to place tiles, roads, and settlements on a responsive SVG Catan board.

CatanSVGReactTypeScriptLinear Algebra

This blog post belongs to a two-part series: Responsive and Resilient Catan Board

  1. Responsive Hexagonal Grids Now Reading
  2. Resilient Road Placement

The Catan board you see above is rendered as a single SVG. Each item on the board is just a child element within this SVG. Whenever game state changes (e.g., a road is placed), the SVG must be updated with new or modified child elements.

We will cover how the Settlers React demo converts a given game state into SVGs like the one above. React’s virtual DOM and memoization is used to only redraw elements with material changes. The browser then handles the responsive sizing of the SVG for us.

This page explains how the demo is hosted.

Our Rendering Strategy

A standard Catan board is a hexagonal grid of tiles with various items placed on it:

  • Chits (the numbers at the center of tiles)
  • Settlements
  • Roads
  • Ports

To draw a Catan board using SVG, we need to find the (x, y) position of each tile or item and then use primitive elements like polygon to draw shapes. Everything in the SVG is represented in Cartesian coordinates ((x, y) points) and a bounding box of extreme x and y values.

In general, SVGs require you to deal with calculating coordinates just once. Browsers then work their magic and properly scale the SVG anywhere it is used. That is the key benefit of drawing the board this way.

It turns out, if you can find the center (x, y) coordinate of each hexagon, all other items can be drawn relative to these centers. So, our approach to rendering the board is this:

  1. Choose how many hexagons we need, how large they will be, and if there will be gaps between them.
  2. Label these hexagons according to the hex coordinate system (covered in the following section).
  3. Translate each hex coordinate to its Cartesian coordinate ((x, y)).
  4. Use geometry (in the Cartesian coordinate system) to find hexagon vertices, road edges, port centers, etc.
  5. Build primitive elements like polygon based on the calculated coordinates.

The (q, r) Hex Coordinate System

There are a lot of good references on hex grids online, so I will only cover the main idea here. The reference grid below shows a coordinate system designed for working with hex grids.

Hexagonal grid coordinate diagram
Credit to Milo Trujillo for this graphic showing the hexagonal grid coordinates: https://backdrifting.net/post/064_hex_grids

Each tile gets a (q, r) tuple, where q is the column coordinate and r is the row coordinate. The awkward thing about this system is that two tiles with the same q value do not sit in the same vertical column. In other words, both the q and r values are needed to determine the x position of a grid element.

We use this coordinate system because it’s well-studied and there is a simple linear transformation from it to Cartesian coordinates. Alternatively, we could have derived the center (x, y) coordinates of the 19 Catan tiles with basic geometry and trigonometry in the Cartesian coordinate system. For Catan, we can be confident the board will never change shape, so manually dealing with this once is a reasonable option.

The hexagonal coordinate system’s strengths would be more apparent if there were a variable number of tiles, the spacing frequently changed, or we needed to reference tiles relative to each other. For example, if we wanted to find tile X that is to the northwest of tile Y. In a full implementation of Catan, this would almost certainly be useful.

Converting Hex Coordinates to Cartesian

The final position of every SVG element is derived from these coordinates. SVG elements have to be defined in terms of traditional (x, y) coordinates, so we need to translate between the two systems.

We can find the Cartesian center of a hex tile with a simple linear transformation. Given a (q, r) pair, we get an (x, y) pair. Note that the matrix reflects our earlier observation that x depends on both q and r.

[xy]=HEX_SIZE[332032][qr]\begin{bmatrix} x \\ y \end{bmatrix} = \text{HEX\_SIZE} \cdot \begin{bmatrix} \sqrt{3} & \frac{\sqrt{3}}{2} \\ 0 & \frac{3}{2} \end{bmatrix} \begin{bmatrix} q \\ r \end{bmatrix}

For (q=2, r=2) with HEX_SIZE = 40:

x=40(32+322)=40(23+3)=4033405.196207.85y=40322=403=120.00(x,y)=(207.85, 120.00)\begin{aligned} x &= 40 \cdot \left(\sqrt{3} \cdot 2 + \frac{\sqrt{3}}{2} \cdot 2\right) \\ &= 40 \cdot \left(2\sqrt{3} + \sqrt{3}\right) \\ &= 40 \cdot 3\sqrt{3} \\ &\approx 40 \cdot 5.196 \\ &\approx 207.85 \\[6pt] y &= 40 \cdot \frac{3}{2} \cdot 2 \\ &= 40 \cdot 3 \\ &= 120.00 \\[6pt] (x, y) &= (207.85,\ 120.00) \end{aligned}

From the center, we can find the six vertices of the hexagon using trigonometry. Each vertex sits on a circle of radius HEX_SIZE around the center, rotated 60 degrees from the previous one.

If you’re curious, you can see the raw SVG elements that the app generates. Note that the center position of the (q=2, r=2) hexagon never appears directly, since each hexagon is a polygon element. polygon elements are entirely defined by their vertices.

Using Geometry to Draw Items

Within gridDataBuilder.ts, we use basic hexagon properties and trigonometry to find all the vertices of each tile.

// get the 6 vertices of a hex centered at (cx, cy)
function hexVertices(p: Point): Point[] {
  const vertices = [];
  for (let i = 0; i < 6; i++) {
    const angle = (60 * i - 30) * (Math.PI / 180); // convert to radians
    const x = p.x + HEX_SIZE * Math.cos(angle);
    const y = p.y + HEX_SIZE * Math.sin(angle);
    vertices.push({ x, y });
  }
  return vertices;
}

We then pass these vertices through to a polygon element (Tile React component).

import { BoardColor } from '../../types/enum/BoardColor'
import type { Point } from '../../types/board-internal/Point'

interface TileProps {
  idExternal: string
  vertices: Point[]
  color: BoardColor
  onClick: (id: string) => void
}

export function Tile({ idExternal, vertices, color, onClick }: TileProps) {
  const points = vertices.map((v) => `${v.x},${v.y}`).join(' ')

  return (
    <polygon
      points={points}
      fill={color}
      stroke={BoardColor.BLACK}
      strokeWidth="1"
      style={{ cursor: 'pointer' }}
      onClick={() => onClick(idExternal)}
    />
  )
}

We have similar code for chits, settlements, roads, and ports. Ports are particularly geometry-heavy because they are trapazoids at somewhat irregular angles.

SVG Elements as Native DOM Nodes

As mentioned earlier, SVG elements (and their children) are native DOM nodes. They have all the same features as the HTML elements you more commonly think of, like <div> or <button>.

  • Queriable via getElementById() and querySelector.
  • Interactivity via onClick and onMouseEnter.
  • Styling via CSS selectors like :hover and :focus.
  • Dynamic creation and deletion via document.createElement and element.remove().

The interactive road placement covered in the following post takes full advantage of these features.