Data Visualization with Pathogen: Building a Radial Bar Chart
Prerequisites: This post uses PathBlocks, TextBlocks, GroupLayers, and for-loop destructuring. If you're new to Pathogen, start with those introductions.
Radial bar charts arrange categorical data around a central point, encoding values as the length of wedge-shaped bars radiating outward. They're visually distinctive — the circular layout invites comparison across categories in a way that a standard bar chart can't — but geometrically demanding. Each bar is an annular sector whose inner and outer arcs, radial edges, and rounded corners all require precise coordinate math.
This post walks through building a radial bar chart in Pathogen, inspired by Patrick Wojda's BoardGameGeek category visualization on Observable. His chart compares how often categories appear across all games versus the top 100 ranked titles — a compelling use of radial layout to reveal patterns in community-assigned board game categories. Along the way, we'll introduce several new language features that emerged from the needs of this project: a native radialWedge() function, TextBlock.radialProject() for rotated label placement, and the VerticalAnchor enum for font-metric-aware text alignment.
The Annular Sector
The fundamental shape in a radial bar chart is the annular sector — a ring segment defined by an inner radius, outer radius, and two angles. In Pathogen, the new radialWedge() stdlib function generates this shape with a single call:
M cx cy
radialWedge(innerR, outerR, fromAngle, toAngle, cornerR)
The center is wherever the cursor is positioned (via M cx cy). The function emits only relative commands (m, a, l, z) — no absolute M — so it composes naturally inside PathBlocks. Angles are in radians (use the deg suffix for degrees — e.g., 90deg, -45deg), with fromAngle / toAngle following the same convention as conic gradients. The cornerR parameter controls the rounding at all four arc-line junctions.
// viewBox="0 0 560 400"
// radialWedge() — the annular sector with automatic rounded corners
let bgColor = Color(CSSVar('--bg-color', #f6efe6));
let barColor = Color(CSSVar('--bar-color', #cc3333));
let textColor = Color('#2f2f2f');
let annotColor = Color('#6b7280');
let guideColor = Color('#c8c0b4');
let leaderColor = Color('#b0a898');
let kwColor = Color('#3b82f6');
let paramColor = Color('#8b5cf6');
let bg = PathLayer('bg') ${ fill: bgColor; stroke: none; };
bg.apply { rect(0, 0, 560, 400) }
// ============================================================
// Group 1: Title
// ============================================================
define GroupLayer('title-group') ${ translate-x: 0; translate-y: 0; }
let title = TextLayer('title') ${
font-size: 13;
fill: textColor;
font-family: system-ui, sans-serif;
font-weight: bold;
text-anchor: start;
};
layer('title-group').append(title);
title.apply {
text(28, 26)`radialWedge(innerR, outerR, fromAngle, toAngle, cornerR)`
}
// ============================================================
// Group 2: Wedge diagram with annotations
// ============================================================
define GroupLayer('diagram') ${ translate-x: 30; translate-y: 50; }
let cx = 130;
let cy = 155;
let innerR = 50;
let outerR = 145;
let fromA = rad(-50);
let toA = rad(35);
// Reference circles (dashed)
let guides = PathLayer('guides') ${ fill: none; stroke: guideColor; stroke-width: 0.6; stroke-dasharray: 4 3; };
layer('diagram').append(guides);
guides.apply {
circle(cx, cy, innerR)
circle(cx, cy, outerR)
}
// Sharp corners (ghost)
let sharpLayer = PathLayer('sharp') ${ fill: barColor; stroke: none; opacity: 0.2; };
layer('diagram').append(sharpLayer);
sharpLayer.apply {
M cx cy
radialWedge(innerR, outerR, fromA, toA, 0)
}
// Rounded corners
let roundLayer = PathLayer('rounded') ${ fill: barColor; stroke: none; };
layer('diagram').append(roundLayer);
roundLayer.apply {
M cx cy
radialWedge(innerR, outerR, fromA, toA, 6)
}
// --- Annotation layers ---
let dots = PathLayer('dots') ${ fill: annotColor; stroke: none; };
let leaders = PathLayer('leaders') ${ fill: none; stroke: leaderColor; stroke-width: 0.5; stroke-dasharray: 2 2; };
let arcs = PathLayer('arcs') ${ fill: none; stroke: annotColor; stroke-width: 0.8; };
let ticks = PathLayer('ticks') ${ fill: none; stroke: annotColor; stroke-width: 0.7; };
let labelsMid = TextLayer('labels-mid') ${
font-size: 9;
fill: textColor;
font-family: system-ui, sans-serif;
text-anchor: middle;
};
let labelsStart = TextLayer('labels-start') ${
font-size: 9;
fill: textColor;
font-family: system-ui, sans-serif;
text-anchor: start;
};
let labelsEnd = TextLayer('labels-end') ${
font-size: 9;
fill: textColor;
font-family: system-ui, sans-serif;
text-anchor: end;
};
layer('diagram').append(arcs, leaders, dots, labelsMid, labelsStart, labelsEnd);
// --- Center dot + label ---
dots.apply { circle(cx, cy, 2.5) }
labelsEnd.apply {
text(calc(cx - 5), calc(cy - 4))`center`
}
// --- innerR: dot on inner radius of wedge, dashed leader to label above ---
let innerDotAngle = calc((fromA + toA) / 2 - 0.15); // slightly off-center on inner arc
let innerDotX = polarX(cx, innerDotAngle, innerR);
let innerDotY = polarY(cy, innerDotAngle, innerR);
dots.apply { circle(innerDotX, innerDotY, 2) }
// Label above the inner concentric circle
let innerLabelX = calc(innerDotX - 12);
let innerLabelY = calc(cy - innerR - 10);
leaders.apply {
M innerDotX innerDotY
L innerLabelX innerLabelY
}
labelsEnd.apply {
text(calc(innerLabelX + 14), calc(innerLabelY - 2))`innerR`
}
// --- outerR label: dot on outer circle at the wedge midpoint, label just outside ---
let outerLabelAngle = calc((fromA + toA) / 2);
let outerDotX = polarX(cx, outerLabelAngle, outerR);
let outerDotY = polarY(cy, outerLabelAngle, outerR);
dots.apply { circle(outerDotX, outerDotY, 2) }
labelsStart.apply {
text(calc(outerDotX + 6), calc(outerDotY + 3))`outerR`
}
// --- fromAngle / toAngle arcs INSIDE the wedge ---
let arcR = 30;
// fromAngle arc (from 0° reference to fromA)
arcs.apply {
M calc(cx + arcR) cy
A arcR arcR 0 0 0 polarX(cx, fromA, arcR) polarY(cy, fromA, arcR)
}
// fromAngle label — short dashed leader to label above-left
let fromTipX = polarX(cx, fromA, arcR);
let fromTipY = polarY(cy, fromA, arcR);
dots.apply { circle(fromTipX, fromTipY, 1.5) }
leaders.apply {
M fromTipX fromTipY
L calc(fromTipX - 16) calc(fromTipY - 10)
}
labelsEnd.apply {
text(calc(fromTipX - 18), calc(fromTipY - 8))`fromAngle`
}
// toAngle arc (from 0° reference to toA)
arcs.apply {
M calc(cx + arcR) cy
A arcR arcR 0 0 1 polarX(cx, toA, arcR) polarY(cy, toA, arcR)
}
// toAngle label — short dashed leader to label below-left
let toTipX = polarX(cx, toA, arcR);
let toTipY = polarY(cy, toA, arcR);
dots.apply { circle(toTipX, toTipY, 1.5) }
leaders.apply {
M toTipX toTipY
L calc(toTipX - 12) calc(toTipY + 12)
}
labelsEnd.apply {
text(calc(toTipX - 14), calc(toTipY + 14))`toAngle`
}
// 0° reference tick
guides.apply { M cx cy L calc(cx + arcR + 3) cy }
// ============================================================
// Group 3: Stdlib function call (code)
// ============================================================
define GroupLayer('code-group') ${ translate-x: 350; translate-y: 50; }
let headerLabel = TextLayer('header') ${
font-size: 10;
fill: textColor;
font-family: system-ui, sans-serif;
font-weight: bold;
text-anchor: start;
};
layer('code-group').append(headerLabel);
headerLabel.apply { text(0, 10)`stdlib function call:` }
let codeLayer = TextLayer('code') ${
font-size: 9.5;
fill: annotColor;
font-family: monospace, monospace;
text-anchor: start;
};
layer('code-group').append(codeLayer);
let kwStyle = ${ fill: kwColor; };
let paramStyle = ${ fill: paramColor; };
let plainStyle = ${ fill: annotColor; };
codeLayer.apply {
text(0, 32) {
tspan(0, 0, 0, kwStyle)`M`
tspan(0, 0, 0, plainStyle)` cx cy`
}
text(0, 48) {
tspan(0, 0, 0, kwStyle)`radialWedge`
tspan(0, 0, 0, plainStyle)`(`
}
text(14, 64) {
tspan(0, 0, 0, paramStyle)`innerR`
tspan(0, 0, 0, plainStyle)`, `
tspan(0, 0, 0, paramStyle)`outerR`
tspan(0, 0, 0, plainStyle)`,`
}
text(14, 80) {
tspan(0, 0, 0, paramStyle)`fromAngle`
tspan(0, 0, 0, plainStyle)`, `
tspan(0, 0, 0, paramStyle)`toAngle`
tspan(0, 0, 0, plainStyle)`,`
}
text(14, 96) {
tspan(0, 0, 0, paramStyle)`cornerR`
}
text(0, 112)`)`
}
// ============================================================
// Group 4: Graceful degradation + corner callout
// ============================================================
define GroupLayer('notes-group') ${ translate-x: 350; translate-y: 195; }
let notesHeader = TextLayer('notes-header') ${
font-size: 10;
fill: textColor;
font-family: system-ui, sans-serif;
font-weight: bold;
text-anchor: start;
};
layer('notes-group').append(notesHeader);
notesHeader.apply { text(0, 10)`Graceful degradation:` }
let notesCode = TextLayer('notes-code') ${
font-size: 9.5;
fill: Color('#6b7280');
font-family: monospace, monospace;
text-anchor: start;
};
layer('notes-group').append(notesCode);
notesCode.apply {
text(0, 30)`// cornerR auto-reduces`
text(0, 46)`// when ends are too narrow`
text(0, 62)`// All relative commands`
text(0, 78)`// No M — composable in @{}`
}
let accentLabels = TextLayer('accent') ${
font-size: 9;
fill: barColor;
font-family: system-ui, sans-serif;
text-anchor: start;
};
layer('notes-group').append(accentLabels);
accentLabels.apply {
text(0, 110)`cornerR = 6 rounds all corners`
text(0, 124)`cornerR = 0 sharp edges (ghost)`
}
The ghost shape shows cornerR = 0 (sharp edges). The solid shape uses cornerR = 6, which rounds all four corners where radial lines meet circular arcs.
Why a stdlib function?
We initially built annular sectors using a PathBlock with heading, tangentArc, turn, and tangentLine:
fn makeWedge(innerR, outerR, sweep, startAngle, cornerR) {
let w = @{
heading(calc(startAngle + 90deg))
tangentArc(innerR, sweep)
turn(-90deg)
tangentLine(calc(outerR - innerR))
turn(-90deg)
tangentArc(outerR, calc(-1 * sweep))
turn(-90deg)
tangentLine(calc(outerR - innerR))
z
};
return w.fillet(cornerR);
}
This approach taught us the heading/turn system well, but it hit real problems at chart scale:
.fillet()didn't handle arc-line transitions — it only rounded line-to-line corners, silently skipping the four arc-line junctions in our wedge. We extended the fillet algorithm to compute tangent directions at arc endpoints, but this revealed deeper issues.- Narrow bars produced degenerate output — when the inner arc was too short for the requested corner radius, the fillet split produced zero-length commands with
undefinedSVG parameters. - Graceful degradation required analytical geometry — the correct maximum corner radius at each end depends on solving
maxCr = R × sin(halfSweep) / (1 ± sin(halfSweep)), which is complex to derive and implement correctly in user code.
These challenges led us to create radialWedge() as a native stdlib function — the same design philosophy as roundRect(): encapsulate edge-case geometry so users get correct output without solving it themselves.
Drawing Radial Bars
A radial bar maps a data value to the outer radius of an annular sector. The inner radius stays constant (forming the center hole), and the bar's length — its radial extent — encodes the value:
let outerR = calc(innerR + (maxR - innerR) * d.all / maxVal);
M cx cy
radialWedge(innerR, outerR, fromAngle, toAngle, cornerR)
To compare two datasets (all games vs top 100), the Observable chart overlays a narrower dark bar on top of each wider red bar. The dark bar uses 50% of the angular width, centered within the slice:
// viewBox="0 0 520 400"
// Radial bar — mapping data values to bar length with radialWedge()
let bgColor = Color(CSSVar('--bg-color', #f6efe6));
let allBarColor = Color(CSSVar('--bar-all', #cc3333));
let topBarColor = Color(CSSVar('--bar-top', #1a1a2e));
let textColor = Color('#2f2f2f');
let annotColor = Color('#6b7280');
let guideColor = Color('#c8c0b4');
let leaderColor = Color('#b0a898');
let kwColor = Color('#3b82f6');
let paramColor = Color('#8b5cf6');
let bg = PathLayer('bg') ${ fill: bgColor; stroke: none; };
bg.apply { rect(0, 0, 520, 400) }
// ============================================================
// Group 1: Title
// ============================================================
define GroupLayer('title-group') ${}
let title = TextLayer('title') ${
font-size: 13;
fill: textColor;
font-family: system-ui, sans-serif;
font-weight: bold;
text-anchor: start;
};
layer('title-group').append(title);
title.apply {
text(28, 26)`Overlaid bars — value-to-radius mapping`
}
// ============================================================
// Group 2: Wedge diagram
// ============================================================
define GroupLayer('diagram') ${ translate-x: 10; translate-y: 40; }
let cx = 105;
let cy = 175;
let innerR = 45;
let maxR = 155;
let maxVal = 20;
let cornerR = 4;
let sliceFrom = rad(-55);
let sliceTo = rad(25);
let sliceMid = calc((sliceFrom + sliceTo) / 2);
// Reference circles
let guides = PathLayer('guides') ${ fill: none; stroke: guideColor; stroke-width: 0.6; stroke-dasharray: 4 3; };
layer('diagram').append(guides);
// Right-half semicircles only — frees up left-side space
guides.apply {
M cx calc(cy - innerR) A innerR innerR 0 0 1 cx calc(cy + innerR)
M cx calc(cy - maxR) A maxR maxR 0 0 1 cx calc(cy + maxR)
}
// "All BGG games" bar — Fantasy 15.6%
let allOuterR = calc(innerR + (maxR - innerR) * 15.6 / maxVal);
let barsAll = PathLayer('bars-all') ${ fill: allBarColor; stroke: bgColor; stroke-width: 0.5; };
layer('diagram').append(barsAll);
barsAll.apply {
M cx cy
radialWedge(innerR, allOuterR, sliceFrom, sliceTo, cornerR)
}
// "Top 100" bar overlaid — Fantasy 7.6%, 50% theta, centered
let topOuterR = calc(innerR + (maxR - innerR) * 7.6 / maxVal);
let topSweep = calc((sliceTo - sliceFrom) * 0.5);
let topOff = calc((sliceTo - sliceFrom - topSweep) / 2);
let barsTop = PathLayer('bars-top') ${ fill: topBarColor; stroke: bgColor; stroke-width: 0.5; };
layer('diagram').append(barsTop);
barsTop.apply {
M cx cy
radialWedge(innerR, topOuterR, calc(sliceFrom + topOff), calc(sliceFrom + topOff + topSweep), cornerR)
}
// Percentage labels — outside each bar tip with dots + leaders
let dots = PathLayer('dots') ${ fill: annotColor; stroke: none; };
let leaders = PathLayer('leaders') ${ fill: none; stroke: leaderColor; stroke-width: 0.5; stroke-dasharray: 2 2; };
let pctLabels = TextLayer('pct') ${
font-size: 9;
fill: textColor;
font-family: system-ui, sans-serif;
text-anchor: start;
};
layer('diagram').append(leaders, dots, pctLabels);
// 15.6% — dot on red bar tip, label outside
let allTipX = polarX(cx, sliceMid, allOuterR);
let allTipY = polarY(cy, sliceMid, allOuterR);
let allLabelR = calc(allOuterR + 14);
dots.apply { circle(allTipX, allTipY, 1.5) }
leaders.apply {
M allTipX allTipY
L polarX(cx, sliceMid, allLabelR) polarY(cy, sliceMid, allLabelR)
}
let allPctStyle = ${ fill: allBarColor; };
pctLabels.apply {
text(polarX(cx, sliceMid, calc(allLabelR + 3)), calc(polarY(cy, sliceMid, calc(allLabelR + 3)) + 3)) {
tspan(0, 0, 0, allPctStyle)`15.6%`
}
}
// 7.6% — dot on dark bar tip, label outside
let topTipX = polarX(cx, sliceMid, topOuterR);
let topTipY = polarY(cy, sliceMid, topOuterR);
let topLabelR = calc(topOuterR + 14);
dots.apply { circle(topTipX, topTipY, 1.5) }
leaders.apply {
M topTipX topTipY
L polarX(cx, calc(sliceMid + 0.15), topLabelR) polarY(cy, calc(sliceMid + 0.15), topLabelR)
}
let topPctStyle = ${ fill: topBarColor; };
pctLabels.apply {
text(polarX(cx, calc(sliceMid + 0.15), calc(topLabelR + 3)), calc(polarY(cy, calc(sliceMid + 0.15), calc(topLabelR + 3)) + 3)) {
tspan(0, 0, 0, topPctStyle)`7.6%`
}
}
// ============================================================
// Group 3: Legend + code
// ============================================================
define GroupLayer('info') ${ translate-x: 330; translate-y: 55; }
// Category name
let catLabel = TextLayer('cat') ${
font-size: 14;
fill: textColor;
font-family: Georgia, serif;
font-weight: bold;
text-anchor: start;
};
layer('info').append(catLabel);
catLabel.apply { text(0, 0)`Fantasy` }
// Legend swatches
let swAll = PathLayer('sw-all') ${ fill: allBarColor; stroke: none; };
let swTop = PathLayer('sw-top') ${ fill: topBarColor; stroke: none; };
let legLabels = TextLayer('leg') ${
font-size: 9;
fill: textColor;
font-family: system-ui, sans-serif;
text-anchor: start;
};
layer('info').append(swAll, swTop, legLabels);
swAll.apply { roundRect(0, 14, 10, 10, 2) }
swTop.apply { roundRect(0, 30, 10, 10, 2) }
legLabels.apply {
text(16, 23)`All BGG games`
text(16, 39)`Top 100 games`
}
// Code — syntax highlighted
let codeLayer = TextLayer('code') ${
font-size: 9;
fill: annotColor;
font-family: monospace, monospace;
text-anchor: start;
};
layer('info').append(codeLayer);
let kwStyle = ${ fill: kwColor; };
let paramStyle = ${ fill: paramColor; };
let plainStyle = ${ fill: annotColor; };
codeLayer.apply {
text(0, 72) {
tspan(0, 0, 0, paramStyle)`outerR`
tspan(0, 0, 0, plainStyle)` = innerR +`
}
text(14, 86) {
tspan(0, 0, 0, plainStyle)`(maxR - innerR) * `
tspan(0, 0, 0, paramStyle)`val`
tspan(0, 0, 0, plainStyle)` / max`
}
text(0, 110) {
tspan(0, 0, 0, kwStyle)`M`
tspan(0, 0, 0, plainStyle)` cx cy`
}
text(0, 124) {
tspan(0, 0, 0, kwStyle)`radialWedge`
tspan(0, 0, 0, plainStyle)`(`
tspan(0, 0, 0, paramStyle)`innerR`
tspan(0, 0, 0, plainStyle)`, `
tspan(0, 0, 0, paramStyle)`outerR`
tspan(0, 0, 0, plainStyle)`,`
}
text(14, 138) {
tspan(0, 0, 0, paramStyle)`fromAngle`
tspan(0, 0, 0, plainStyle)`, `
tspan(0, 0, 0, paramStyle)`toAngle`
tspan(0, 0, 0, plainStyle)`, 4)`
}
}
// ============================================================
// Group 4: Bottom note
// ============================================================
define GroupLayer('note') ${ translate-x: 330; translate-y: 270; }
let noteLabel = TextLayer('note-text') ${
font-size: 9;
fill: allBarColor;
font-family: system-ui, sans-serif;
text-anchor: start;
};
layer('note').append(noteLabel);
noteLabel.apply {
text(0, 0)`Dark bar: 50% angular width,`
text(0, 14)`centered within the red bar's slice`
}
Arranging Categories
With radialWedge() handling individual bars, arranging multiple categories is a for loop over a data array. The syntax for ([d, i] in data) destructures each element into the value d and its index i — a pattern you'll see throughout the rest of this post. Each category gets an angular slice of TAU() / count radians, with a slight overlap between adjacent wedges:
// viewBox="0 0 600 600"
// Category layout — 8 categories arranged radially with radialWedge()
let bgColor = Color(CSSVar('--bg-color', #f6efe6));
let allBarColor = Color(CSSVar('--bar-all', #cc3333));
let topBarColor = Color(CSSVar('--bar-top', #1a1a2e));
let textColor = Color('#2f2f2f');
let gridColor = Color('#d4c9b8');
let bg = PathLayer('bg') ${ fill: bgColor; stroke: none; };
bg.apply { rect(0, 0, 600, 600) }
// === Chart parameters ===
let cx = 300;
let cy = 310;
let innerR = 20;
let maxR = 220;
let maxVal = 18;
let cornerR = 4;
// === Data: 8-category subset ===
let data = [
{ name: "Fantasy", all: 15.6, top: 7.6 },
{ name: "Wargame", all: 13.8, top: 2.2 },
{ name: "Sci-Fi", all: 8.9, top: 5.7 },
{ name: "Economic", all: 6.0, top: 9.7 },
{ name: "Adventure", all: 6.5, top: 4.9 },
{ name: "Exploration", all: 3.0, top: 4.3 },
{ name: "Medieval", all: 2.8, top: 3.8 },
{ name: "Farming", all: 1.4, top: 2.4 }
];
let count = data.length;
let sliceAngle = calc(TAU() / count);
let startOffset = PI(); // Fantasy at 9:00
let overlap = rad(0.3);
let barSweep = calc(sliceAngle + overlap);
// === Grid rings ===
let grid = PathLayer('grid') ${ fill: none; stroke: gridColor; stroke-width: 0.5; stroke-dasharray: 3 4; };
grid.apply {
circle(cx, cy, calc(innerR + (maxR - innerR) * 5 / maxVal))
circle(cx, cy, calc(innerR + (maxR - innerR) * 10 / maxVal))
}
// Center circle
let center = PathLayer('center') ${ fill: none; stroke: gridColor; stroke-width: 0.6; };
center.apply { circle(cx, cy, innerR) }
// === Draw bars ===
let barsAll = PathLayer('bars-all') ${ fill: allBarColor; stroke: bgColor; stroke-width: 0.5; };
let barsTop = PathLayer('bars-top') ${ fill: topBarColor; stroke: bgColor; stroke-width: 0.5; };
for ([d, i] in data) {
let sliceFrom = calc(startOffset + i * sliceAngle);
let sliceTo = calc(sliceFrom + barSweep);
let allOuterR = calc(innerR + (maxR - innerR) * d.all / maxVal);
barsAll.apply {
M cx cy
radialWedge(innerR, allOuterR, sliceFrom, sliceTo, cornerR)
}
if (d.top > 0) {
let topOuterR = calc(innerR + (maxR - innerR) * d.top / maxVal);
let topSweep = calc(barSweep * 0.5);
let topOffset = calc((barSweep - topSweep) / 2);
barsTop.apply {
M cx cy
radialWedge(innerR, topOuterR, calc(sliceFrom + topOffset), calc(sliceFrom + topOffset + topSweep), cornerR)
}
}
}
// === Legend ===
let swAll = PathLayer('sw-all') ${ fill: allBarColor; stroke: none; };
let swTop = PathLayer('sw-top') ${ fill: topBarColor; stroke: none; };
let legLabels = TextLayer('legend') ${
font-size: 10;
fill: textColor;
font-family: system-ui, sans-serif;
text-anchor: start;
};
swAll.apply { roundRect(230, 572, 10, 10, 2) }
swTop.apply { roundRect(340, 572, 10, 10, 2) }
legLabels.apply {
text(244, 582)`All BGG games`
text(354, 582)`Top 100 games`
}
// === Title ===
let title = TextLayer('title') ${
font-size: 13;
fill: textColor;
font-family: system-ui, sans-serif;
font-weight: bold;
text-anchor: middle;
};
title.apply {
text(300, 30)`8 categories with radialWedge() — for ([d, i] in data) { ... }`
}
let g = GroupLayer('all') ${};
g.append(bg, grid, center, barsAll, barsTop, swAll, swTop, legLabels, title);
The background-colored stroke (stroke: bgColor; stroke-width: 0.5) on each wedge creates the thin separation lines between adjacent bars — a subtle detail from the Observable original that gives the chart visual crispness.
Rotated Labels with radialProject
Placing labels around a radial chart is the trickiest part. Each label must be:
- Positioned just past the bar's tip
- Rotated to align with the radial direction
- Flipped on the left hemisphere so text reads left-to-right
- Vertically centered so the text midline — not baseline — aligns with the bar's angular center
Doing this manually requires separate TextLayers for left and right hemispheres, manual cos/sin positioning, angle normalization for hemisphere detection, and a font-size-dependent y-offset for vertical centering. The new .radialProject() method on TextBlock handles all of this in one call:
let label = &{ text(0, 0)`${d.name}` } << ${ font-size: 11; };
catLabels.apply {
label.radialProject(cx, cy, midAngle, labelR,
'start', 1, VerticalAnchor.Midline).draw()
}
The seven arguments:
cx— chart center xcy— chart center ymidAngle— radial direction (radians)labelR— distance from center'start'— text extends away from center (or'end'for inward)1— auto-flip enabled (detects left hemisphere, flips 180° for readability)VerticalAnchor.Midline— which vertical font metric aligns with the projected point
// viewBox="0 0 700 700"
// Radial labels — radialProject with VerticalAnchor.Midline
let bgColor = Color(CSSVar('--bg-color', #f6efe6));
let allBarColor = Color(CSSVar('--bar-all', #cc3333));
let topBarColor = Color(CSSVar('--bar-top', #1a1a2e));
let textColor = Color('#2f2f2f');
let gridColor = Color('#d4c9b8');
let pctColor = Color('#6b7280');
let bg = PathLayer('bg') ${ fill: bgColor; stroke: none; };
bg.apply { rect(0, 0, 700, 700) }
// === Chart parameters ===
let cx = 350;
let cy = 360;
let innerR = 20;
let maxR = 220;
let maxVal = 18;
let cornerR = 4;
// === Data ===
let data = [
{ name: "Fantasy", all: 15.6, top: 7.6 },
{ name: "Wargame", all: 13.8, top: 2.2 },
{ name: "Sci-Fi", all: 8.9, top: 5.7 },
{ name: "Economic", all: 6.0, top: 9.7 },
{ name: "Adventure", all: 6.5, top: 4.9 },
{ name: "Exploration", all: 3.0, top: 4.3 },
{ name: "Medieval", all: 2.8, top: 3.8 },
{ name: "Farming", all: 1.4, top: 2.4 }
];
let count = data.length;
let sliceAngle = calc(TAU() / count);
let startOffset = PI();
let overlap = rad(0.3);
let barSweep = calc(sliceAngle + overlap);
// === Grid rings + center ===
let grid = PathLayer('grid') ${ fill: none; stroke: gridColor; stroke-width: 0.5; stroke-dasharray: 3 4; };
grid.apply {
circle(cx, cy, calc(innerR + (maxR - innerR) * 5 / maxVal))
circle(cx, cy, calc(innerR + (maxR - innerR) * 10 / maxVal))
}
let center = PathLayer('center') ${ fill: none; stroke: gridColor; stroke-width: 0.6; };
center.apply { circle(cx, cy, innerR) }
// === Draw bars ===
let barsAll = PathLayer('bars-all') ${ fill: allBarColor; stroke: bgColor; stroke-width: 0.5; };
let barsTop = PathLayer('bars-top') ${ fill: topBarColor; stroke: bgColor; stroke-width: 0.5; };
for ([d, i] in data) {
let sliceFrom = calc(startOffset + i * sliceAngle);
let sliceTo = calc(sliceFrom + barSweep);
let allOuterR = calc(innerR + (maxR - innerR) * d.all / maxVal);
barsAll.apply {
M cx cy
radialWedge(innerR, allOuterR, sliceFrom, sliceTo, cornerR)
}
if (d.top > 0) {
let topOuterR = calc(innerR + (maxR - innerR) * d.top / maxVal);
let topSweep = calc(barSweep * 0.5);
let topOff = calc((barSweep - topSweep) / 2);
barsTop.apply {
M cx cy
radialWedge(innerR, topOuterR, calc(sliceFrom + topOff), calc(sliceFrom + topOff + topSweep), cornerR)
}
}
}
// === Radial labels — single TextLayer via radialProject + VerticalAnchor ===
let catLabels = TextLayer('cat-labels') ${
font-size: 11;
fill: textColor;
font-family: Georgia, serif;
font-weight: bold;
};
let redDot = ${ fill: allBarColor; white-space: pre; font-size: 8.8; };
let redPct = ${ fill: allBarColor; font-weight: normal; font-size: 8.8; };
let darkDot = ${ fill: topBarColor; white-space: pre; font-size: 8.8; };
let darkPct = ${ fill: topBarColor; font-weight: normal; font-size: 8.8; };
for ([d, i] in data) {
let midAngle = calc(startOffset + (i + 0.5) * sliceAngle);
let longerVal = max(d.all, d.top);
let barTipR = calc(innerR + (maxR - innerR) * longerVal / maxVal);
let labelR = calc(barTipR + 8);
let labelTb = &{
text(0, 0) {
`${d.name}`
tspan(0, 0, 0, redDot)` · `
tspan(0, 0, 0, redPct)`${d.all}%`
tspan(0, 0, 0, darkDot)` · `
tspan(0, 0, 0, darkPct)`${d.top}%`
}
} << ${ font-size: 11; };
catLabels.apply {
labelTb.radialProject(cx, cy, midAngle, labelR, 'start', 1, VerticalAnchor.Midline).draw()
}
}
// === Legend ===
let swAll = PathLayer('sw-all') ${ fill: allBarColor; stroke: none; };
let swTop = PathLayer('sw-top') ${ fill: topBarColor; stroke: none; };
let legLabels = TextLayer('legend') ${
font-size: 10;
fill: textColor;
font-family: system-ui, sans-serif;
text-anchor: start;
};
swAll.apply { roundRect(270, 672, 10, 10, 2) }
swTop.apply { roundRect(380, 672, 10, 10, 2) }
legLabels.apply {
text(284, 682)`All BGG games`
text(394, 682)`Top 100 games`
}
// === Title ===
let title = TextLayer('title') ${
font-size: 13;
fill: textColor;
font-family: system-ui, sans-serif;
font-weight: bold;
text-anchor: middle;
};
title.apply {
text(350, 28)`radialProject + VerticalAnchor.Midline — one TextLayer, no branching`
}
let g = GroupLayer('all') ${};
g.append(bg, grid, center, barsAll, barsTop, catLabels,
swAll, swTop, legLabels, title);
The VerticalAnchor Enum
Without VerticalAnchor, labels on the lower half of the chart drift away from their bars. The text baseline (where glyphs sit) is below the visual center of the text. When the label is rotated, this offset translates into a radial misalignment.
VerticalAnchor.Midline shifts the projected point perpendicular to the radial direction by fontSize × 0.35 — placing the x-height center (the visual middle of lowercase letters) exactly on the bar's angular midpoint. Other options: VerticalAnchor.Baseline (default, no shift), VerticalAnchor.CapHeight (top of capitals), and VerticalAnchor.Descender (bottom of descenders).
Testing with Diagnostic Matrices
Building radialWedge() required multiple iterations to get the corner geometry right. To verify correctness across the full parameter space, we built diagnostic matrices — grids of wedges with varying angular width and outer radius, each rendered with guide circles, a dotted sharp-corner reference outline, and an XOR diff layer that highlights any geometric differences between the sharp and rounded versions.
// viewBox="0 0 900 700"
// radialWedge diagnostic matrix: sharp outline + rounded fill + XOR diff
let bgColor = Color(CSSVar('--bg-color', #f6efe6));
let wedgeColor = Color('#cc3333').lighten(20%);
let xorColor = Color('#cc3333').darken(20%);
let outlineColor = Color('#22aa44');
let guideColor = Color('#4477aa');
let textColor = Color('#2f2f2f');
let bg = PathLayer('bg') ${ fill: bgColor; stroke: none; };
bg.apply { rect(0, 0, 900, 700) }
let innerR = 30;
let cornerR = 4;
let thetas = [0.05pi, 0.1pi, 0.15pi, 0.2pi, 0.3pi, 0.5pi];
let thetaLabels = ["0.05pi", "0.1pi", "0.15pi", "0.2pi", "0.3pi", "0.5pi"];
let outerRs = [50, 70, 100, 140];
let outerLabels = ["oR=50", "oR=70", "oR=100", "oR=140"];
let colSpacing = 140;
let rowSpacing = 160;
let startX = 80;
let startY = 90;
// Column headers
let headers = TextLayer('headers') ${
font-size: 10;
fill: textColor;
font-family: monospace, monospace;
text-anchor: middle;
};
headers.apply {
for ([label, ci] in thetaLabels) {
text(calc(startX + ci * colSpacing), 30)`${label}`
}
}
// Row headers
let rowHeaders = TextLayer('row-headers') ${
font-size: 10;
fill: textColor;
font-family: monospace, monospace;
text-anchor: end;
};
rowHeaders.apply {
for ([label, ri] in outerLabels) {
text(45, calc(startY + ri * rowSpacing + 5))`${label}`
}
}
// Layers in draw order (bottom to top)
let wedges = PathLayer('wedges') ${ fill: wedgeColor; stroke: none; };
let xorLayer = PathLayer('xor') ${ fill: xorColor; stroke: none; };
let outlines = PathLayer('outlines') ${ fill: none; stroke: outlineColor; stroke-width: 1; stroke-dasharray: 0.01 1.6; stroke-linecap: round; };
let guides = PathLayer('guides') ${ fill: none; stroke: guideColor; stroke-width: 0.8; stroke-dasharray: 3 3; };
let info = TextLayer('info') ${
font-size: 7;
fill: textColor;
font-family: monospace, monospace;
text-anchor: middle;
};
for ([oR, ri] in outerRs) {
for ([theta, ci] in thetas) {
let cx = calc(startX + ci * colSpacing);
let cy = calc(startY + ri * rowSpacing);
let halfTheta = calc(theta / 2);
let fromA = calc(-90deg - halfTheta);
let toA = calc(-90deg + halfTheta);
// 1. Rounded wedge (lightened)
wedges.apply {
M cx cy
radialWedge(innerR, oR, fromA, toA, cornerR)
}
// 2. XOR between sharp and rounded
let sharp = @{ radialWedge(innerR, oR, fromA, toA, 0) };
let rounded = @{ radialWedge(innerR, oR, fromA, toA, cornerR) };
let diff = sharp.project(cx, cy).xor(rounded.project(cx, cy));
xorLayer.apply { diff.drawTo(0, 0) }
// 3. Sharp outline (dotted green)
outlines.apply {
M cx cy
radialWedge(innerR, oR, fromA, toA, 0)
}
// 4. Guide circles (on top)
guides.apply {
circle(cx, cy, innerR)
circle(cx, cy, oR)
}
// Effective corner radii info
let absSweep = theta;
let halfSweep = calc(absSweep / 2);
let sinHalf = sin(halfSweep);
let maxICr = calc(innerR * sinHalf / (1 - sinHalf));
let maxOCr = calc(oR * sinHalf / (1 + sinHalf));
let iCr = min(cornerR, calc((oR - innerR) / 2), maxICr);
let oCr = min(cornerR, calc((oR - innerR) / 2), maxOCr);
info.apply {
text(cx, calc(cy + oR + 14))`iCr:${round(iCr)} oCr:${round(oCr)}`
}
}
}
// Title
let title = TextLayer('title') ${
font-size: 13;
fill: textColor;
font-family: system-ui, sans-serif;
font-weight: bold;
text-anchor: start;
};
title.apply {
text(60, 670)`radialWedge diagnostics — cr=${cornerR}, iR=${innerR} | green=sharp, dark=XOR diff, blue=guide circles`
}
let g = GroupLayer('all') ${};
g.append(bg, wedges, xorLayer, outlines, guides, headers, rowHeaders, info, title);
// viewBox="0 0 900 700"
// radialWedge diagnostic matrix: sharp outline + rounded fill + XOR diff
let bgColor = Color(CSSVar('--bg-color', #f6efe6));
let wedgeColor = Color('#cc3333').lighten(20%);
let xorColor = Color('#cc3333').darken(20%);
let outlineColor = Color('#22aa44');
let guideColor = Color('#4477aa');
let textColor = Color('#2f2f2f');
let bg = PathLayer('bg') ${ fill: bgColor; stroke: none; };
bg.apply { rect(0, 0, 900, 700) }
let innerR = 30;
let cornerR = 16;
let thetas = [0.05pi, 0.1pi, 0.15pi, 0.2pi, 0.3pi, 0.5pi];
let thetaLabels = ["0.05pi", "0.1pi", "0.15pi", "0.2pi", "0.3pi", "0.5pi"];
let outerRs = [50, 70, 100, 140];
let outerLabels = ["oR=50", "oR=70", "oR=100", "oR=140"];
let colSpacing = 140;
let rowSpacing = 160;
let startX = 80;
let startY = 90;
// Column headers
let headers = TextLayer('headers') ${
font-size: 10;
fill: textColor;
font-family: monospace, monospace;
text-anchor: middle;
};
headers.apply {
for ([label, ci] in thetaLabels) {
text(calc(startX + ci * colSpacing), 30)`${label}`
}
}
// Row headers
let rowHeaders = TextLayer('row-headers') ${
font-size: 10;
fill: textColor;
font-family: monospace, monospace;
text-anchor: end;
};
rowHeaders.apply {
for ([label, ri] in outerLabels) {
text(45, calc(startY + ri * rowSpacing + 5))`${label}`
}
}
// Layers in draw order (bottom to top)
let wedges = PathLayer('wedges') ${ fill: wedgeColor; stroke: none; };
let xorLayer = PathLayer('xor') ${ fill: xorColor; stroke: none; };
let outlines = PathLayer('outlines') ${ fill: none; stroke: outlineColor; stroke-width: 1; stroke-dasharray: 0.01 1.6; stroke-linecap: round; };
let guides = PathLayer('guides') ${ fill: none; stroke: guideColor; stroke-width: 0.8; stroke-dasharray: 3 3; };
let info = TextLayer('info') ${
font-size: 7;
fill: textColor;
font-family: monospace, monospace;
text-anchor: middle;
};
for ([oR, ri] in outerRs) {
for ([theta, ci] in thetas) {
let cx = calc(startX + ci * colSpacing);
let cy = calc(startY + ri * rowSpacing);
let halfTheta = calc(theta / 2);
let fromA = calc(-90deg - halfTheta);
let toA = calc(-90deg + halfTheta);
// 1. Rounded wedge (lightened)
wedges.apply {
M cx cy
radialWedge(innerR, oR, fromA, toA, cornerR)
}
// 2. XOR between sharp and rounded
let sharp = @{ radialWedge(innerR, oR, fromA, toA, 0) };
let rounded = @{ radialWedge(innerR, oR, fromA, toA, cornerR) };
let diff = sharp.project(cx, cy).xor(rounded.project(cx, cy));
xorLayer.apply { diff.drawTo(0, 0) }
// 3. Sharp outline (dotted green)
outlines.apply {
M cx cy
radialWedge(innerR, oR, fromA, toA, 0)
}
// 4. Guide circles (on top)
guides.apply {
circle(cx, cy, innerR)
circle(cx, cy, oR)
}
// Effective corner radii info
let absSweep = theta;
let halfSweep = calc(absSweep / 2);
let sinHalf = sin(halfSweep);
let maxICr = calc(innerR * sinHalf / (1 - sinHalf));
let maxOCr = calc(oR * sinHalf / (1 + sinHalf));
let iCr = min(cornerR, calc((oR - innerR) / 2), maxICr);
let oCr = min(cornerR, calc((oR - innerR) / 2), maxOCr);
info.apply {
text(cx, calc(cy + oR + 14))`iCr:${round(iCr)} oCr:${round(oCr)}`
}
}
}
// Title
let title = TextLayer('title') ${
font-size: 13;
fill: textColor;
font-family: system-ui, sans-serif;
font-weight: bold;
text-anchor: start;
};
title.apply {
text(60, 670)`radialWedge diagnostics — cr=${cornerR}, iR=${innerR} | green=sharp, dark=XOR diff, blue=guide circles`
}
let g = GroupLayer('all') ${};
g.append(bg, wedges, xorLayer, outlines, guides, headers, rowHeaders, info, title);
The dark red regions show the XOR between the sharp-cornered and rounded-cornered wedges. In a correct implementation, these should appear only at the four corners where rounding removes material. The cornerR = 16 matrix demonstrates the graceful degradation: when the inner arc is too narrow for full-radius corners, radialWedge() analytically computes the largest corner radius that fits each end independently.
This matrix-based testing approach — rendering a grid of parameter combinations with geometric overlays — proved invaluable for identifying edge cases during development. The XOR diff layer made it immediately visible when a corner fillet was misaligned or a sweep flag was inverted, issues that would have been nearly impossible to catch by inspecting individual examples.
The Complete Chart
Bringing everything together: 26 BoardGameGeek categories, overlaid red and dark bars, rotated labels with inline colored percentages via tspan styling, annotation badges, a wedge legend, and a summary bar chart — all driven by a single data array:
// viewBox="0 0 1000 750"
// Complete radial hierarchical bar chart — BoardGameGeek categories
// === Theme colors (CSSVar for interactive switching) ===
let bgColor = Color(CSSVar('--bg-color', #f6efe6));
let textColor = Color(CSSVar('--text-color', #2f2f2f));
let allBarColor = Color(CSSVar('--bar-all', #cc3333));
let topBarColor = Color(CSSVar('--bar-top', #1a1a2e));
let gridColor = Color('#d4c9b8').darken(20%);
let annotColor = Color('#6b7280');
let subtleColor = Color('#9ca3af');
let bg = PathLayer('bg') ${ fill: bgColor; stroke: none; };
bg.apply { rect(0, 0, 1000, 750) }
// === Chart parameters ===
// Center in the middle of the canvas; Fantasy's long bar extends left,
// small bars leave room on the right for annotations
let cx = 500;
let cy = 395;
let innerR = 24;
let barGap = 1;
let barInnerR = calc(innerR + barGap);
let maxR = 413;
let maxVal = 18;
let cornerR = 4;
// === Full dataset (sorted by "all" descending) ===
let data = [
{ name: "Fantasy", all: 15.6, top: 7.6 },
{ name: "Wargame", all: 13.8, top: 2.2 },
{ name: "Science Fiction", all: 8.9, top: 5.7 },
{ name: "Adventure", all: 6.5, top: 4.9 },
{ name: "Economic", all: 6.0, top: 9.7 },
{ name: "Animals", all: 5.8, top: 2.8 },
{ name: "Humor", all: 3.7, top: 3.5 },
{ name: "Murder/Mystery", all: 3.5, top: 1.2 },
{ name: "Civilization", all: 3.2, top: 2.0 },
{ name: "Exploration", all: 3.0, top: 4.3 },
{ name: "Medieval", all: 2.8, top: 3.8 },
{ name: "Mythology", all: 2.5, top: 1.0 },
{ name: "Political", all: 1.7, top: 0.3 },
{ name: "Farming", all: 1.4, top: 2.4 },
{ name: "Music", all: 1.4, top: 0.0 },
{ name: "Travel", all: 1.4, top: 0.5 },
{ name: "Nautical", all: 1.2, top: 0.3 },
{ name: "Ancient", all: 1.0, top: 0.8 },
{ name: "Mafia", all: 0.9, top: 0.0 },
{ name: "Spies / Secret", all: 0.8, top: 0.0 },
{ name: "Transportation", all: 0.7, top: 1.6 },
{ name: "Religious", all: 0.5, top: 0.5 },
{ name: "Prehistoric", all: 0.4, top: 0.4 },
{ name: "Age of Reason", all: 0.3, top: 0.4 },
{ name: "Medical", all: 0.3, top: 0.3 },
{ name: "Arabian", all: 0.1, top: 0.3 }
];
let count = data.length;
let sliceAngle = calc(TAU() / count);
// Fantasy centered at 9:00 (west), CW from there
let startOffset = calc(PI() - sliceAngle / 2);
// Slight overlap between adjacent wedges (like Observable)
let overlap = rad(0.3);
let barSweep = calc(sliceAngle + overlap);
let topBarSweep = calc(barSweep * 0.5);
let topBarOffset = calc((barSweep - topBarSweep) / 2);
// === Grid rings ===
define GroupLayer('chart') ${}
// Solid center circle
let centerCircle = PathLayer('center-circle') ${ fill: none; stroke: gridColor; stroke-width: 0.8; };
layer('chart').append(centerCircle);
centerCircle.apply { circle(cx, cy, innerR) }
// Dashed concentric grid rings at 5% and 10%
let grid = PathLayer('grid') ${ fill: none; stroke: gridColor; stroke-width: 1; stroke-dasharray: 3 4; };
layer('chart').append(grid);
grid.apply {
circle(cx, cy, calc(barInnerR + (maxR - barInnerR) * 5 / maxVal))
circle(cx, cy, calc(barInnerR + (maxR - barInnerR) * 10 / maxVal))
}
let gridLabels = TextLayer('grid-labels') ${
font-size: 7;
fill: subtleColor;
font-family: system-ui, sans-serif;
text-anchor: middle;
};
layer('chart').append(gridLabels);
gridLabels.apply {
text(cx, calc(cy - barInnerR - (maxR - barInnerR) * 5 / maxVal - 3))`5%`
text(cx, calc(cy - barInnerR - (maxR - barInnerR) * 10 / maxVal - 3))`10%`
}
// === Draw categories — each category in its own GroupLayer ===
// Contains: red bar, dark bar, category label, badge (if any)
// Draw in data order: Fantasy (i=0) lowest z-index, each subsequent stacks higher
// Shared label styles
let pctSize = 7.2;
let redDot = ${ fill: allBarColor; white-space: pre; font-size: pctSize; };
let redPct = ${ fill: allBarColor; font-weight: normal; font-size: pctSize; };
let darkDot = ${ fill: topBarColor; white-space: pre; font-size: pctSize; };
let darkPct = ${ fill: topBarColor; font-weight: normal; font-size: pctSize; };
// Extract first word for group naming
let firstWords = [
"Fantasy", "Wargame", "Science", "Adventure", "Economic",
"Animals", "Humor", "Murder", "Civilization", "Exploration",
"Medieval", "Mythology", "Political", "Farming", "Music",
"Travel", "Nautical", "Ancient", "Mafia", "Spies",
"Transportation", "Religious", "Prehistoric", "Age", "Medical", "Arabian"
];
for ([d, i] in data) {
let sliceFrom = calc(startOffset + i * sliceAngle);
let sliceTo = calc(sliceFrom + barSweep);
let midAngle = calc(startOffset + (i + 0.5) * sliceAngle);
// Create per-category GroupLayer
let catGroup = GroupLayer(`group-${i}-${firstWords[i]}`) ${};
layer('chart').append(catGroup);
// Red bar (all BGG games)
if (d.all > 0) {
let outerR = calc(barInnerR + (maxR - barInnerR) * d.all / maxVal);
let barLayer = PathLayer(`bar-all-${i}`) ${ fill: allBarColor; stroke: bgColor; stroke-width: 1; };
catGroup.append(barLayer);
barLayer.apply {
M cx cy
radialWedge(barInnerR, outerR, sliceFrom, sliceTo, cornerR)
}
}
// Dark bar overlaid (top 100)
if (d.top > 0) {
let topOuterR = calc(barInnerR + (maxR - barInnerR) * d.top / maxVal);
let topFrom = calc(sliceFrom + topBarOffset);
let topTo = calc(topFrom + topBarSweep);
let topLayer = PathLayer(`bar-top-${i}`) ${ fill: topBarColor; stroke: bgColor; stroke-width: 1; };
catGroup.append(topLayer);
topLayer.apply {
M cx cy
radialWedge(barInnerR, topOuterR, topFrom, topTo, cornerR)
}
}
// Category label
let longerVal = max(d.all, d.top);
let barTipR = calc(barInnerR + (maxR - barInnerR) * longerVal / maxVal);
let labelR = calc(barTipR + 8);
let catLabel = TextLayer(`label-${i}`) ${
font-size: 9;
fill: textColor;
font-family: Georgia, serif;
font-weight: bold;
};
catGroup.append(catLabel);
let labelTb = &{
text(0, 0) {
`${d.name}`
tspan(0, 0, 0, redDot)` · `
tspan(0, 0, 0, redPct)`${d.all}%`
tspan(0, 0, 0, darkDot)` · `
tspan(0, 0, 0, darkPct)`${d.top}%`
}
} << ${ font-size: 9; };
catLabel.apply {
labelTb.radialProject(cx, cy, midAngle, labelR, 'start', 1, VerticalAnchor.Midline).draw()
}
// Badge (if applicable)
let hasBadge = d.name == "Economic" || d.name == "Wargame" || d.name == "Humor" || d.name == "Music" || d.name == "Mafia" || d.name == "Prehistoric" ? 1 : 0;
if (hasBadge == 1) {
let labelW = labelTb.boundingBox().width;
let badgeR = calc(labelR + labelW + 8);
let bx = polarX(cx, midAngle, badgeR);
let by = polarY(cy, midAngle, badgeR);
if (d.name == "Economic") {
let bc = PathLayer(`badge-circ-${i}`) ${ fill: textColor; stroke: none; };
let bs = PathLayer(`badge-star-${i}`) ${ fill: bgColor; stroke: none; };
catGroup.append(bc, bs);
bc.apply { circle(bx, by, 4.5) }
bs.apply { star(bx, by, 3.5, 1.4, 5) }
}
if (d.name == "Wargame") {
let bc = PathLayer(`badge-circ-${i}`) ${ fill: allBarColor; stroke: none; };
let bs = PathLayer(`badge-star-${i}`) ${ fill: bgColor; stroke: none; };
catGroup.append(bc, bs);
bc.apply { circle(bx, by, 4.5) }
bs.apply { star(bx, by, 3.5, 1.4, 5) }
}
if (d.name == "Humor" || d.name == "Music" || d.name == "Mafia" || d.name == "Prehistoric") {
let bs = PathLayer(`badge-star-${i}`) ${ fill: textColor; stroke: none; };
catGroup.append(bs);
bs.apply { star(bx, by, 4, 1.6, 5) }
}
}
}
// === Title and subtitle ===
define GroupLayer('chrome') ${}
let titleLayer = TextLayer('title') ${
font-size: 20;
fill: textColor;
font-family: Georgia, serif;
font-weight: bold;
text-anchor: middle;
};
layer('chrome').append(titleLayer);
titleLayer.apply {
text(500, 45)`Fantasy and war dominate board games`
text(500, 73)`(they are just not the best)`
}
// Subtitle with highlighted phrases
// Each segment rendered as a separate text element at measured positions,
// so rectangles and text share the same anchor points.
let subStyle = ${ font-size: 11; font-family: system-ui, sans-serif; };
let subY = 100;
let subPad = 3;
// Measure each segment
let seg1Tb = &{ text(0, 11)`They make up nearly 30% of ` } << subStyle;
let segHi1Tb = &{ text(0, 11)`all board games` } << subStyle;
let seg2Tb = &{ text(0, 11)`, but just about 10% of the ` } << subStyle;
let segHi2Tb = &{ text(0, 11)`top 100` } << subStyle;
let seg3Tb = &{ text(0, 11)` ranked titles.` } << subStyle;
let w1 = seg1Tb.boundingBox().width;
let wHi1 = segHi1Tb.boundingBox().width;
let w2 = seg2Tb.boundingBox().width;
let wHi2 = segHi2Tb.boundingBox().width;
let w3 = seg3Tb.boundingBox().width;
let totalW = calc(w1 + wHi1 + w2 + wHi2 + w3);
let subLineStart = calc(500 - totalW / 2);
// Position highlights using proportional offsets from center.
// The fraction of the line before each highlight is stable regardless of
// the actual rendered width — it only depends on character count ratios.
let frac1 = calc(w1 / totalW); // fraction before "all board games"
let fracHi1 = calc(wHi1 / totalW); // fraction of "all board games"
let frac2 = calc((w1 + wHi1 + w2) / totalW); // fraction before "top 100"
let fracHi2 = calc(wHi2 / totalW); // fraction of "top 100"
// Estimate browser's rendered width — our measurement is ~10% narrow for system-ui
let browserW = calc(totalW * 1.12);
let browserStart = calc(500 - browserW / 2);
let subtitleBg = PathLayer('subtitle-bg') ${ fill: allBarColor; stroke: none; };
let subtitleBg2 = PathLayer('subtitle-bg2') ${ fill: topBarColor; stroke: none; };
layer('chrome').append(subtitleBg, subtitleBg2);
subtitleBg.apply {
roundRect(calc(browserStart + frac1 * browserW - subPad + 4), calc(subY - 11), calc((fracHi1 * browserW + subPad * 2) * 0.92), 15, 2)
}
subtitleBg2.apply {
roundRect(calc(browserStart + frac2 * browserW - subPad - 6), calc(subY - 11), calc((fracHi2 * browserW + subPad * 2) * 0.90), 15, 2)
}
// Render as a single text element with tspans for color changes.
// The browser positions tspans using its own font metrics (continuous flow),
// which is correct. The rectangles above use our measured positions.
// Accept ~1-2px offset as the cost of approximate character width tables.
let subtitleLayer = TextLayer('subtitle') ${
font-size: 11;
fill: annotColor;
font-family: system-ui, sans-serif;
text-anchor: middle;
};
layer('chrome').append(subtitleLayer);
let hiWhite = ${ fill: #ffffff; };
subtitleLayer.apply {
text(500, subY) {
`They make up nearly 30% of `
tspan(0, 0, 0, hiWhite)`all board games`
`, but just about 10% of the `
tspan(0, 0, 0, hiWhite)`top 100`
` ranked titles.`
}
}
// === Wedge Legend (bottom-left) ===
define GroupLayer('legend') ${ translate-x: 94; translate-y: 540; }
let legIR = 6;
let legBarStart = 7; // 1-unit gap from center circle
let legSweep = 54deg;
let legCR = 2;
let legRedColor = allBarColor.lighten(8%);
let legDarkColor = topBarColor.lighten(8%);
let legOX = -17; // diagram + title x offset
let legOY = 14; // diagram y offset
// Wedge angles: small → medium → large going CW
let legA1 = 95deg;
let legA2 = calc(legA1 + legSweep + 3deg);
let legA3 = calc(legA2 + legSweep + 3deg);
// Red wedge fan — lightened 8%
let legRedWedges = PathLayer('leg-red') ${ fill: legRedColor; stroke: bgColor; stroke-width: 0.5; };
layer('legend').append(legRedWedges);
legRedWedges.apply {
M legOX legOY
radialWedge(legBarStart, 25, legA1, calc(legA1 + legSweep), legCR)
M legOX legOY
radialWedge(legBarStart, 38, legA2, calc(legA2 + legSweep), legCR)
M legOX legOY
radialWedge(legBarStart, 60, legA3, calc(legA3 + legSweep), legCR)
}
// Right-half semicircles + center for red
let legRedGuides = PathLayer('leg-red-guides') ${ fill: none; stroke: gridColor; stroke-width: 0.4; stroke-dasharray: 2 2; };
layer('legend').append(legRedGuides);
legRedGuides.apply {
M legOX calc(legOY - 20) A 20 20 0 0 1 legOX calc(legOY + 20)
M legOX calc(legOY - 34) A 34 34 0 0 1 legOX calc(legOY + 34)
}
let legRedCenter = PathLayer('leg-red-ctr') ${ fill: none; stroke: gridColor; stroke-width: 0.6; };
layer('legend').append(legRedCenter);
legRedCenter.apply { circle(legOX, legOY, legIR) }
// Dark wedge fan — same radii, lightened 8%
let legDX = 130;
let legDarkX = calc(legDX + legOX);
let legDarkWedges = PathLayer('leg-dark') ${ fill: legDarkColor; stroke: bgColor; stroke-width: 0.5; };
layer('legend').append(legDarkWedges);
legDarkWedges.apply {
M legDarkX legOY
radialWedge(legBarStart, 25, legA1, calc(legA1 + legSweep), legCR)
M legDarkX legOY
radialWedge(legBarStart, 38, legA2, calc(legA2 + legSweep), legCR)
M legDarkX legOY
radialWedge(legBarStart, 60, legA3, calc(legA3 + legSweep), legCR)
}
// Right-half semicircles + center for dark
let legDarkGuides = PathLayer('leg-dark-guides') ${ fill: none; stroke: gridColor; stroke-width: 0.4; stroke-dasharray: 2 2; };
layer('legend').append(legDarkGuides);
legDarkGuides.apply {
M legDarkX calc(legOY - 20) A 20 20 0 0 1 legDarkX calc(legOY + 20)
M legDarkX calc(legOY - 34) A 34 34 0 0 1 legDarkX calc(legOY + 34)
}
let legDarkCenter = PathLayer('leg-dark-ctr') ${ fill: none; stroke: gridColor; stroke-width: 0.6; };
layer('legend').append(legDarkCenter);
legDarkCenter.apply { circle(legDarkX, legOY, legIR) }
// 5%/10% labels
let legGuideLabels = TextLayer('leg-guide-labels') ${
font-size: 7;
fill: subtleColor.darken(20%);
font-family: system-ui, sans-serif;
text-anchor: start;
};
layer('legend').append(legGuideLabels);
legGuideLabels.apply {
text(calc(legDarkX + 22), calc(legOY - 5), 90deg)`5%`
text(calc(legDarkX + 36), calc(legOY - 5), 90deg)`10%`
}
// Legend text labels
let legLabels = TextLayer('leg-labels') ${
font-size: 9;
fill: textColor;
font-family: Georgia, serif;
font-weight: bold;
text-anchor: middle;
};
layer('legend').append(legLabels);
legLabels.apply {
text(legOX, 70)`All BoardGameGeek games`
text(legDarkX, 70)`Top 100 games`
}
// Footnote
let footNote = TextLayer('footnote') ${
font-size: 8;
fill: annotColor;
font-family: system-ui, sans-serif;
text-anchor: start;
};
layer('legend').append(footNote);
footNote.apply {
text(-80, 90)`Each bar shows how often a category appears across games.`
text(-80, 102)`Percentages are normalized separately for each group.`
}
// === Top Categories Bar Chart (bottom-right) ===
// Dots on a gentle leftward arc, bars extend left from dots, labels right
define GroupLayer('summary') ${ translate-x: 680; translate-y: 496; }
let summData = [
{ name: "Economic", pct: 9.7, rank: 1 },
{ name: "Fantasy", pct: 7.6, rank: 2 },
{ name: "Science Fiction", pct: 5.7, rank: 3 },
{ name: "Adventure", pct: 4.9, rank: 4 },
{ name: "Exploration", pct: 4.3, rank: 5 }
];
let summBarMax = 10;
let summBarW = 91;
let summBarH = 8.5;
let summDotR = calc(summBarH / 2 + 0.75);
// Dots on a gentle circular arc bowing leftward
// Arc center is to the right of the dots; dots sit on the left side of the circle
let summDotY0 = 48; // first dot y
let summItemStep = 18; // vertical step between bars
let summTotalH = calc(4 * summItemStep); // total height span of 5 items
let summArcCx = 250; // arc center far to the right
let summArcCy = calc(summDotY0 + summTotalH / 2); // vertically centered on bars
let summArcR = calc(summArcCx - 150); // radius so leftmost point is at x≈150
// Dot position: on the circle at the correct y for each item
fn summDotXAt(idx) {
let dy = calc(summDotY0 + idx * summItemStep - summArcCy);
return calc(summArcCx - sqrt(summArcR * summArcR - dy * dy));
}
// Title — centered directly above the bar cluster
let summTitle = TextLayer('summ-title') ${
font-size: 11;
fill: textColor;
font-family: Georgia, serif;
font-weight: bold;
text-anchor: middle;
};
layer('summary').append(summTitle);
let summTitleX = calc(summDotXAt(0));
summTitle.apply {
text(summTitleX, calc(summDotY0 - 31))`Top categories among the 100`
text(summTitleX, calc(summDotY0 - 18))`highest-ranked board games`
}
// Circular arc behind dots — extends 8 units above first dot and below last dot
let summArcLineLayer = PathLayer('summ-arc') ${ fill: none; stroke: topBarColor; stroke-width: 1; };
layer('summary').append(summArcLineLayer);
let summArcTopY = calc(summDotY0 - 8);
let summArcBotY = calc(summDotY0 + 4 * summItemStep + 8);
let summArcTopDy = calc(summArcTopY - summArcCy);
let summArcBotDy = calc(summArcBotY - summArcCy);
let summArcTopX = calc(summArcCx - sqrt(summArcR * summArcR - summArcTopDy * summArcTopDy));
let summArcBotX = calc(summArcCx - sqrt(summArcR * summArcR - summArcBotDy * summArcBotDy));
summArcLineLayer.apply {
M summArcTopX summArcTopY
A summArcR summArcR 0 0 0 summArcBotX summArcBotY
}
// Bars, dots, labels
let summBars = PathLayer('summ-bars') ${ fill: topBarColor; stroke: none; };
let summDots = PathLayer('summ-dots') ${ fill: bgColor; stroke: topBarColor; stroke-width: 1; };
let summLabels = TextLayer('summ-labels') ${
font-size: 9;
fill: textColor;
font-family: system-ui, sans-serif;
text-anchor: start;
};
layer('summary').append(summBars, summDots, summLabels);
for ([s, si] in summData) {
let dotX = summDotXAt(si);
let dotY = calc(summDotY0 + si * summItemStep);
let bw = calc(summBarW * s.pct / summBarMax);
// Bar extends LEFT from dot
summBars.apply {
roundRect(calc(dotX - bw), calc(dotY - summDotR), bw, summBarH, 5)
}
// Open circle on the arc — shifted up 0.75 to center on bar
summDots.apply { circle(dotX, calc(dotY - 0.75), summDotR) }
// Label to the RIGHT of dot
summLabels.apply {
text(calc(dotX + 8), calc(dotY + 3))`#${s.rank} ${s.name} ${s.pct}%`
}
}
// === Annotation badges — on a concentric circle outside the 10% ring ===
// Uniformly spaced, flowing around the chart like the bars do
let annotR = calc(barInnerR + (maxR - barInnerR) * 11.75 / maxVal);
let annotStart = rad(-45);
// Badge 1: Economic overperforms — star in dark circle
let a1Angle = annotStart;
let a1x = polarX(cx, a1Angle, annotR);
let a1y = polarY(cy, a1Angle, annotR);
let badge1Circle = PathLayer('badge1-circle') ${ fill: textColor; stroke: none; };
let badge1Star = PathLayer('badge1-star') ${ fill: bgColor; stroke: none; };
layer('chrome').append(badge1Circle, badge1Star);
badge1Circle.apply { circle(a1x, a1y, 6.5) }
badge1Star.apply { star(a1x, a1y, 5.5, 2.2, 5) }
let badge1Text = TextLayer('badge1-text') ${
font-size: 9;
fill: textColor;
font-family: system-ui, sans-serif;
text-anchor: start;
};
layer('chrome').append(badge1Text);
let annotFontSize = 9;
let annotYShift = calc(annotFontSize * 1.2);
badge1Text.apply {
text(calc(a1x + 14), calc(a1y - 8 + annotYShift))`Economic games are far more`
text(calc(a1x + 14), calc(a1y + 4 + annotYShift))`common in the top 100 than among`
text(calc(a1x + 14), calc(a1y + 16 + annotYShift))`board games overall`
}
// Badge 2: Wargames widespread but scarce — star in red circle
let a2Angle = calc(annotStart + 18deg);
let a2x = polarX(cx, a2Angle, annotR);
let a2y = polarY(cy, a2Angle, annotR);
let badge2Circle = PathLayer('badge2-circle') ${ fill: allBarColor; stroke: none; };
let badge2Star = PathLayer('badge2-star') ${ fill: bgColor; stroke: none; };
layer('chrome').append(badge2Circle, badge2Star);
badge2Circle.apply { circle(a2x, a2y, 6.5) }
badge2Star.apply { star(a2x, a2y, 5.5, 2.2, 5) }
let badge2Text = TextLayer('badge2-text') ${
font-size: 9;
fill: allBarColor;
font-family: system-ui, sans-serif;
text-anchor: start;
};
layer('chrome').append(badge2Text);
badge2Text.apply {
text(calc(a2x + 14), calc(a2y - 8 + annotYShift))`Wargames are widespread among`
text(calc(a2x + 14), calc(a2y + 4 + annotYShift))`board games overall but scarce in`
text(calc(a2x + 14), calc(a2y + 16 + annotYShift))`the top 100`
}
// Badge 3: No games in top 100 — solid star
let a3Angle = calc(annotStart + 18deg + 27deg);
let a3x = polarX(cx, a3Angle, annotR);
let a3y = polarY(cy, a3Angle, annotR);
let badge3Star = PathLayer('badge3-star') ${ fill: textColor; stroke: none; };
layer('chrome').append(badge3Star);
badge3Star.apply { star(a3x, a3y, 6.5, 2.6, 5) }
let badge3Text = TextLayer('badge3-text') ${
font-size: 9;
fill: textColor;
font-family: system-ui, sans-serif;
text-anchor: start;
};
layer('chrome').append(badge3Text);
badge3Text.apply {
text(calc(a3x + 14), calc(a3y - 8 + annotYShift))`No games from this category`
text(calc(a3x + 14), calc(a3y + 4 + annotYShift))`appear in the top 100`
}
// === Disclaimer and source attribution ===
let disclaimer = TextLayer('disclaimer') ${
font-size: 8;
fill: annotColor;
font-family: Georgia, serif;
font-style: italic;
text-anchor: start;
};
layer('chrome').append(disclaimer);
disclaimer.apply {
text(120, 705)`Categories reflect community-assigned board-game categories, not gameplay mechanics. Formats were excluded, and overlapping or overly specific labels were excluded in`
text(120, 717)`favour of broader categories. Percentages are normalized separately for each group, showing relative preference rather than total volume.`
}
let sourceNote = TextLayer('source') ${
font-size: 8;
fill: annotColor;
font-family: system-ui, sans-serif;
text-anchor: start;
};
layer('chrome').append(sourceNote);
sourceNote.apply {
text(120, 735)`Source: BoardGameGeek (boardgame categories)`
}
Key techniques in the complete chart:
- Per-wedge layers drawn in data order — each subsequent bar stacks higher, creating the slight overlap effect from the Observable original
- Background-colored stroke (
stroke: bgColor; stroke-width: 1) for separation lines between adjacent wedges - Inline tspan styling for the
Name · all% · top%label format — red interpunct and percentage, dark interpunct and percentage, withwhite-space: preto preserve spaces around the interpunct - Badge icons using
star(cx, cy, outerR, innerR, 5)— filled circle with cream star cutout for the circled variants, solid fill for the standalone star - Labels follow bar tips —
labelR = barTipR + 8so labels sit just past each bar's end, not at a uniform distance
Summary Bar Chart
The radial chart excels at showing the overall distribution pattern, but a linear layout makes precise value comparison easier. The companion horizontal bar chart below the main visualization shows the top 5 categories among the highest-ranked games, making the ranking immediately scannable:
// viewBox="0 0 450 280"
// Summary bar chart — reusing the same data in a horizontal layout
let bgColor = Color(CSSVar('--bg-color', #f6efe6));
let barColor = Color(CSSVar('--bar-top', #1a1a2e));
let accentColor = Color(CSSVar('--bar-all', #cc3333));
let textColor = Color('#2f2f2f');
let subtleColor = Color('#9ca3af');
let bg = PathLayer('bg') ${ fill: bgColor; stroke: none; };
bg.apply { rect(0, 0, 450, 280) }
// === Data ===
let top5 = [
{ name: "Economic", pct: 9.7 },
{ name: "Fantasy", pct: 7.6 },
{ name: "Science Fiction", pct: 5.7 },
{ name: "Adventure", pct: 4.9 },
{ name: "Exploration", pct: 4.3 }
];
// === Chart layout ===
let chartX = 140;
let chartY = 60;
let barMaxW = 220;
let barH = 22;
let barSpacing = 32;
let maxVal = 12;
// === Title ===
let title = TextLayer('title') ${
font-size: 14;
fill: textColor;
font-family: Georgia, serif;
font-weight: bold;
text-anchor: start;
};
title.apply {
text(30, 35)`Top categories among the 100 highest-ranked board games`
}
// === Draw bars ===
let bars = PathLayer('bars') ${ fill: barColor; stroke: none; };
let barLabels = TextLayer('labels') ${
font-size: 11;
fill: textColor;
font-family: system-ui, sans-serif;
text-anchor: end;
};
let barValues = TextLayer('values') ${
font-size: 10;
fill: subtleColor;
font-family: system-ui, sans-serif;
text-anchor: start;
};
let rankLabels = TextLayer('ranks') ${
font-size: 10;
fill: accentColor;
font-family: system-ui, sans-serif;
font-weight: bold;
text-anchor: end;
};
for ([item, idx] in top5) {
let y = calc(chartY + idx * barSpacing);
let bw = calc(barMaxW * item.pct / maxVal);
bars.apply { roundRect(chartX, y, bw, barH, 3) }
rankLabels.apply {
text(45, calc(y + 15))`#${calc(idx + 1)}`
}
barLabels.apply {
text(calc(chartX - 8), calc(y + 15))`${item.name}`
}
barValues.apply {
text(calc(chartX + bw + 8), calc(y + 15))`${item.pct}%`
}
}
// === Footnote ===
let foot = TextLayer('footnote') ${
font-size: 8;
fill: subtleColor;
font-family: system-ui, sans-serif;
font-style: italic;
text-anchor: start;
};
foot.apply {
text(30, 258)`The same reusable functions power both the radial chart and this summary view.`
}
let g = GroupLayer('all') ${};
g.append(bg, bars, barLabels, barValues, rankLabels, title, foot);
New Features Summary
This project prompted several additions to the Pathogen language:
| Feature | What it does |
|---|---|
radialWedge() |
Annular sector with automatic rounded corners and graceful degradation |
.radialProject() |
Positions, rotates, and flips text along a radial direction |
VerticalAnchor |
Controls which font metric aligns with the projected point |
polarX() / polarY() |
Reduces cx + cos(angle) * r boilerplate |
normalizeAngle() |
Normalizes angles to the 0 to TAU range |
| Ternary expressions | condition ? trueVal : falseVal in any expression context |
| Fillet arc-line support | .fillet() now handles arc↔line corners |
The radial bar chart pattern — data array, angular distribution loop, radialWedge() for geometry, radialProject() for labels — is reusable for any categorical comparison that benefits from a circular layout. Try changing the --bar-all and --bar-top color variables in any of the examples above to explore different palettes, or modify the data array to add your own categories.
For the full function signatures and parameter details, see the stdlib reference and TextBlock documentation. The original visualization by Patrick Wojda that inspired this chart is available on Observable.