How to: drawing the Westeros alliance maps
This is the companion to Game of Thrones as a data model. That post is about modeling — parties, typed relationships, TreeList — and deliberately stops at what the diagrams show. This one is about how they're made, so you can regenerate every figure yourself, or point the same machinery at your own data.
You need: Node.js 18+ (no npm packages), and optionally Graphviz for the quick path. The two scripts referenced below ship next to this post: tools/generate-westeros.mjs and tools/emit-dot.mjs.
One dataset, every figure
Both generators read the same two structures at the top of generate-westeros.mjs — panels (organizations with their members) and dated relations. This is the JavaScript mirror of the C# seed from the main post:
const PANELS = [
{ id: "stark", name: "House Stark", sub: "Winterfell", x: 452, y: 200, w: 196,
members: [
{ id: "eddard", name: "Eddard", died: 299 },
{ id: "robb", name: "Robb", died: 299 },
// ...
]},
// ... 16 panels across two eras
];
const RELATIONS = [
{ from: "stark", to: "throne", kind: "fealty", label: "sworn 283", start: 283, end: 299 },
{ from: "robert", to: "cersei", kind: "marriage", label: "m. 285", start: 285, end: 298 },
// ... ~45 dated relations, 128–300 AC
];
Everything downstream is derived: end != null draws dashed with a †year, the snapshot figures filter on a date, and the TreeList ledgers are computed by a direct port of the TreeList init logic included in the script.
Path A — Graphviz does the layout
The quick path emits DOT text and lets dot lay it out. In C# it's one method over the parties and relationships:
string N(Party p) => $"p{p.Id}";
string Dagger(Party p) => p.EndDate is { } d ? $" †{d.Year}" : "";
var colors = new Dictionary<string, string>
{
["SWORN_TO"] = "gray45", ["MARRIED_TO"] = "#2a78d6",
["ALLIED_WITH"] = "#eb6834", ["CLAIMS"] = "#1baf7a",
};
var sb = new StringBuilder();
sb.AppendLine("digraph westeros {");
sb.AppendLine(" rankdir=BT;"); // fealty flows upward: vassal below liege
sb.AppendLine(" node [fontname=\"Georgia\" fontsize=11 shape=plaintext];");
sb.AppendLine(" edge [fontname=\"Georgia\" fontsize=9];");
// organizations become clusters; MEMBER_OF becomes containment, not an edge
foreach (var org in parties.OfType<Organization>())
{
sb.AppendLine($" subgraph cluster_{org.Id} {{ style=rounded; color=gray70; label=\"\";");
sb.AppendLine($" {N(org)} [label=<<B>{org.Title.ToUpper()}</B>>];");
foreach (var m in oaths.Where(r => r.Parent == org
&& r.RelationshipType!.Code == "MEMBER_OF"))
sb.AppendLine($" {N(m.Child!)} [label=\"{m.Child!.Title}{Dagger(m.Child!)}\"];");
sb.AppendLine(" }");
}
// every other relationship becomes a typed, dated edge
foreach (var r in oaths.Where(x => x.RelationshipType!.Code != "MEMBER_OF"))
{
var label = $"{r.RelationshipType!.Title} {r.StartDate?.Year}"
+ (r.EndDate is { } e ? $" †{e.Year}" : "");
var dashed = r.EndDate != null ? " style=dashed" : "";
var dir = r.RelationshipType.Code is "MARRIED_TO" or "ALLIED_WITH" ? " dir=none" : "";
var c = colors[r.RelationshipType.Code!];
sb.AppendLine(
$" {N(r.Child!)} -> {N(r.Parent!)} [label=\"{label}\" color=\"{c}\" fontcolor=\"{c}\"{dashed}{dir}];");
}
sb.AppendLine("}");
File.WriteAllText("westeros.dot", sb.ToString());
tools/emit-dot.mjs is the same logic in JavaScript, reading the shared dataset. Render it:
node tools/emit-dot.mjs
dot -Tsvg westeros.dot -o westeros-simple.svg
Every rule in the emitter 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's already enough; when it isn't, 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's not a library — it's ~250 lines of 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 its kind, path, label position and the relation's dates. 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 rendered through a date predicate — dead members get a struck-through style the same way:
const activeAt = (e, year) => (e.start ?? 0) <= year && (e.end == null || e.end > year);
const edges = snapshot ? ALL_EDGES.filter((e) => activeAt(e, year)) : ALL_EDGES;
const isDead = (m, year) => m.died != null && m.died <= year;
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
# tools/ledger-texts.json (the TreeList ledgers, computed, not hand-typed)
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's the entire QA loop, and after any data change it's the loop you rerun. (If you want it scripted, a ten-line Playwright screenshot loop does the same headlessly, but a browser tab is honestly enough.)
To point this at your own domain: replace PANELS and RELATIONS, 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: nudging panel coordinates and edge via-points until nothing overlaps. That is the trade Path B makes — Graphviz's layout engine for your layout taste. The data rules never change; only opinions do.