← All articles

Drawing the Westeros Alliance Maps

How the figures are made, and how to point the same tooling at your own data

How the Game of Thrones data-model diagrams are made, so you can reproduce them or draw your own data the same way. One dataset, a Graphviz quick path, and a small dependency-free SVG renderer.
Drawing the Westeros Alliance Maps

This is the companion to Westeros, the Iron Throne, and a Model for Complex Relationships and The Realm at Any Date. Those posts are about modeling — parties, typed relationships, dated edges, TreeList — and stop at what the diagrams show. This one is about how they are made, so you can regenerate every figure yourself or point the same machinery at your own data.

You need: Node.js 22.12 or later (no npm packages), and optionally Graphviz for the quick path. The two scripts and the data they read ship next to this post: tools/generate-westeros.mjs, tools/emit-dot.mjs and tools/westeros.json. The .NET 10 SDK is needed only to change the data.

Regenerate everything (from the folder that holds tools/):

node tools/emit-dot.mjs && dot -Tsvg tools/westeros.dot -o tools/westeros-simple.svg   # Path A: the Graphviz map
node tools/generate-westeros.mjs                                                        # Path B: every figure

One dataset, every figure

Neither script holds any data. Both read tools/westeros.json, which the sample project behind the two posts writes from its CSV files — the same rows its trees are folded from:

dotnet run --project path/to/10-westeros-parties -- --json tools/westeros.json

The figure pipeline: four CSV files feed the 10-westeros-parties sample, which prints the trees the posts show and exports tools/westeros.json. generate-westeros.mjs reads that file, holds the layout opinion and writes the era map and the dated snapshots; emit-dot.mjs takes its panels and relations and writes westeros.dot for dot -Tsvg to render as westeros-simple.svg.

The file holds every party and every dated relationship, and for each date a figure shows, what the realm held then: the parties that existed, the relationships the ActiveAt query returned, and the tree the sample printed for that date, if any. Trimmed to a few of each:

{
  "parties": [
    { "code": "stark", "type": "ORGANIZATION", "title": "House Stark", "seat": "Winterfell", "region": "The North" },
    { "code": "catelyn", "type": "PERSON", "title": "Catelyn Stark", "given": "Catelyn", "family": "Stark",
      "note": "née Tully", "from": 264, "until": 299 }
  ],
  "relationships": [
    { "id": 10, "parent": "throne", "child": "stark", "type": "SWORN_TO", "from": 283, "until": 299, "order": 0 }
  ],
  "snapshots": {
    "297": { "exists": ["throne", "baratheon"], "active": [2, 3, 10],
             "tree": [{ "party": "throne", "children": [{ "party": "jaime", "tie": 20, "note": "sworn 281", "children": [] }] }] }
  }
}

What stays in the script is the picture's opinion, keyed by those codes: where each panel sits, which line stands for which relationship and how it runs, and a few words where the data would say it differently:

const panels = layOut([283, 300], [                 // era 1: who existed in 283–300
  { id: "stark",       x: 452, y: 200, w: 196 },
  { id: "nightswatch", x: 24,  y: 200, w: 176, sub: "the Wall" },
  // ... a panel lists the house's members of that era, in their SortOrder
]);
V.push(edge("throne>stark:SWORN_TO@283",            // parent>child:TYPE, @from for a renewed oath
  ortho([[x, y], [x, rail], [779, rail], [779, 88]]), x, 176));

Everything downstream is derived: a relationship with an until draws dashed with a †year, a snapshot draws only what its date returned and grays whoever no longer existed, and a label reads from the relationship's type and year (sworn 283, m. 285) unless the layout words it itself (takes the black 298).

Path A — Graphviz does the layout

The quick path emits DOT text and lets dot lay it out. tools/emit-dot.mjs prints the same panels and lines as the full figure; the same in C#, over the parties and relationships from the main post, is about forty lines. Render it:

node tools/emit-dot.mjs
dot -Tsvg tools/westeros.dot -o tools/westeros-simple.svg

The emitter takes its panels and relations from generate-westeros.mjs (importing it also rewrites the Path B figures), and every drawing rule in it is a data rule — containment for membership, one color per relationship type, dashed + †year for anything ended. What Graphviz cannot know is your layout opinion, which is why the automatic output is correct but flat. For many domains that is already enough; when it is not, you graduate to Path B.

Path B — a small SVG renderer of your own

The polished figures come from tools/generate-westeros.mjs emitting SVG text directly. It is not a library; it is plain string-building on five ingredients.

1 · Panels are rectangles with computed heights. Positions (x, y) are hand-tuned constants — the layout opinion, encoded once; heights follow from member count:

const HEADER_H = 40, ROW_H = 19, PAD_B = 9;
const h = HEADER_H + members.length * ROW_H + PAD_B;
const rowY = (panel, i) => panel.y + HEADER_H + i * ROW_H + ROW_H / 2 - 2;

2 · Two path builders draw every line. Structural edges (fealty, crown) use orthogonal elbows with rounded corners; relational edges (marriage, alliance, claim) use a Catmull-Rom curve through a few hand-placed via-points:

function ortho(pts, r = 8) {           // [[x,y],…] -> elbow path with rounded corners
  let d = `M ${pts[0][0]} ${pts[0][1]}`;
  for (let i = 1; i < pts.length - 1; i++) {
    const [x0, y0] = pts[i - 1], [x1, y1] = pts[i], [x2, y2] = pts[i + 1];
    const v1 = [Math.sign(x1 - x0), Math.sign(y1 - y0)], v2 = [Math.sign(x2 - x1), Math.sign(y2 - y1)];
    d += ` L ${x1 - v1[0] * r} ${y1 - v1[1] * r} Q ${x1} ${y1} ${x1 + v2[0] * r} ${y1 + v2[1] * r}`;
  }
  return d + ` L ${pts.at(-1)[0]} ${pts.at(-1)[1]}`;
}

function curve(pts) {                  // Catmull-Rom -> cubic bézier through all points
  let d = `M ${pts[0][0]} ${pts[0][1]}`;
  for (let i = 0; i < pts.length - 1; i++) {
    const p0 = pts[i - 1] ?? pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] ?? p2;
    d += ` C ${p1[0] + (p2[0] - p0[0]) / 6} ${p1[1] + (p2[1] - p0[1]) / 6},`
       + ` ${p2[0] - (p3[0] - p1[0]) / 6} ${p2[1] - (p3[1] - p1[1]) / 6}, ${p2[0]} ${p2[1]}`;
  }
  return d;
}

3 · Edges are data too. Each visual edge carries the relationship it stands for, its kind, path and label position. Draw order: edges first, then panels (so lines tuck underneath), then label pills with a surface-colored backing rectangle so text stays readable where lines cross.

4 · Snapshots are a filter, not a redraw. The same edge list, narrowed to what the sample's query returned for that date — so the date rule lives in one place, the search object, and a figure cannot disagree with the tree beside it. The dead get a struck-through style the same way:

const at = realm.snapshots[year];
const edges = snapshot ? ALL_EDGES.filter((e) => at.active.includes(e.tie.id)) : ALL_EDGES;
const isDead = (m) => !at.exists.includes(m.id);

5 · Colors are CSS variables with fallbacks, so one file works standalone and adapts when embedded in a page that defines dark-mode tokens:

<path class="edge marriage" stroke="var(--marriage, #2a78d6)" ... />

Generate everything (outputs land next to the script — in tools/ — since the generator writes relative to import.meta.url, not your working directory):

node tools/generate-westeros.mjs
# tools/westeros-alliances.svg   (the full 298–300 era, dashed history)
# tools/westeros-297.svg / -300.svg / -128.svg / -129.svg

Proofing and adapting

An SVG is checked by looking at it: open the file in any browser, zoom to 100%, and hunt for label collisions. That is the entire QA loop, and after any data change it is the loop you rerun.

To point this at your own domain: export your rows in the same shape, keep the kinds (or rename them — each kind is just a CSS class and a color), and expect to spend your time in exactly one place, the layout: nudging panel coordinates and edge via-points until nothing overlaps. The data rules never change; only opinions do.

Why not stay on Graphviz? Path A needs no coordinates: dot places every panel and line, which is why it is the path to start on. Path B trades Graphviz's layout engine for your layout taste, and from then on the coordinates are yours to maintain.

Docs: Graphviz and the DOT language.

Built with

Regira packages

This is one of the pieces we build client applications with: entity CRUD over EF Core, Office documents, file storage and more, published on nuget.org.

All Regira packages Package documentation Free tier & licensing