Every day on GitHub, millions of developers build the products their customers rely on, contribute to open source, and pursue personal projects. GitHub’s architecture has changed steadily over the years to support that work and the growing demands of the developers and organizations who depend on it.
Agentic software development is driving the next architectural shift. With developers and agents working concurrently in repositories that receive millions of commits a day, these workloads demand a different Git architecture. We’re rebuilding GitHub’s Git infrastructure to support them. This post explores the demands shaping that work and the design principles behind it.
Today’s highest-volume workloads show the scale we’re building for. The gap between a typical repository and the busiest ones is wider than most people expect. Here’s a rough picture of the monthly repository activity distribution on GitHub from August 2026:
.ghchart {
/* Theme tokens */
–ghc-text: #1f2328;
–ghc-muted: #656d76;
–ghc-grid: #d0d7de;
–ghc-axis: #59636e;
–ghc-card-bg: #ffffff;
–ghc-card-border: #d0d7de;
–ghc-canvas: #f2f5f3;
–ghc-divider: #b6bfb8;
–ghc-guide: #afb8c1;
/* GitHub brand font — matches the github.blog stack (Mona Sans, with
the blog’s metric fallback) so it renders identically in-context. */
font-family: “Mona Sans”, “Mona Sans Fallback”, -apple-system, “system-ui”,
“Segoe UI”, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
position: relative;
box-sizing: border-box;
width: 100%;
max-width: 699px;
margin: 0 auto;
color: var(–ghc-text);
line-height: 1.4;
}
.ghchart *,
.ghchart *::before,
.ghchart *::after { box-sizing: border-box; }
/* —————————————————————— */
/* Header + legend */
/* —————————————————————— */
.ghchart__header {
display: flex;
justify-content: flex-end;
align-items: center;
min-height: 24px;
margin-bottom: 4px;
}
.ghchart__legend {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
justify-content: flex-end;
}
.ghchart__legend-item {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: clamp(14px, 1.9vw, 17px);
font-weight: 600;
color: var(–ghc-muted);
white-space: nowrap;
}
.ghchart__swatch {
width: 14px;
height: 14px;
border-radius: 4px;
flex: 0 0 auto;
}
/* —————————————————————— */
/* Plot (SVG) */
/* —————————————————————— */
.ghchart__plot {
display: block;
width: 100%;
height: auto;
overflow: visible;
touch-action: pan-y;
}
.ghchart__gridline {
stroke: var(–ghc-grid);
stroke-width: 1;
stroke-dasharray: 2 6;
stroke-linecap: round;
opacity: 0.9;
}
.ghchart__axislabel {
fill: var(–ghc-axis);
font-size: 22px;
font-weight: 500;
}
.ghchart__axislabel–x { font-weight: 600; }
.ghchart__line {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Trailing partial/incomplete segment of a line (e.g. a month still in
progress) — dashed so it reads as “not final yet” without a legend. */
.ghchart__line–dashed {
stroke-dasharray: 7 6;
}
.ghchart__dot { transition: r 0.12s ease; }
/* Dashed threshold line (e.g. a target/reference level) + floating label.
Stroke color is set inline per chart; dash + label styling live here. */
.ghchart__threshold {
stroke-width: 2;
stroke-dasharray: 7 6;
stroke-linecap: round;
opacity: 0.9;
}
.ghchart__threshold-label {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.01em;
}
/* Hatched band marking a partial / projected trailing region. */
.ghchart__projection { pointer-events: none; }
.ghchart__guide {
stroke: var(–ghc-guide);
stroke-width: 1.5;
stroke-dasharray: 4 4;
opacity: 0;
transition: opacity 0.12s ease;
}
.ghchart__hit { fill: transparent; }
/* —————————————————————— */
/* Scatter plot */
/* —————————————————————— */
.ghchart__gridline–v { /* vertical gridlines reuse the dashed grid style */ }
.ghchart__axistitle {
fill: var(–ghc-muted);
font-size: 20px;
font-weight: 600;
letter-spacing: -0.01em;
}
/* Dotted reference crosshair through the “reference” point. Stroke color
is set inline (tracks the reference series color). */
.ghchart__crosshair {
stroke-width: 2;
stroke-dasharray: 2 7;
stroke-linecap: round;
opacity: 0.85;
pointer-events: none;
}
.ghchart__halo { opacity: 0.18; pointer-events: none; }
.ghchart__pt { transition: r 0.12s ease; }
.ghchart__pt-label {
fill: var(–ghc-muted);
font-size: 19px;
font-weight: 600;
letter-spacing: -0.01em;
pointer-events: none;
}
.ghchart__pt-label–strong { font-size: 21px; font-weight: 700; }
.ghchart__pthit { fill: transparent; cursor: default; outline: none; }
.ghchart__pthit:focus-visible {
outline: 2px solid var(–ghc-axis);
outline-offset: 1px;
}
.ghchart__swatch–dot { border-radius: 50%; }
/* —————————————————————— */
/* Trend chart (dated time-series with narrative headline) */
/* —————————————————————— */
.ghchart__headline {
font-size: clamp(17px, 2.3vw, 20px);
line-height: 1.32;
font-weight: 700;
letter-spacing: -0.01em;
color: var(–ghc-text);
margin: 0 0 8px;
}
/* Trend legend sits left-aligned under the headline/subtitle. */
.ghchart__legend–trend {
justify-content: flex-start;
margin: 2px 0 14px;
}
.ghchart__glyph { flex: 0 0 auto; }
.ghchart__glyph–square {
width: 13px; height: 13px; border-radius: 2px;
}
.ghchart__glyph–diamond {
width: 12px; height: 12px; border: 1.5px solid; transform: rotate(45deg);
}
.ghchart__glyph–line {
width: 20px; height: 0; border-top: 2px dashed; border-radius: 0;
}
/* Phase background columns + separators + top labels. */
.ghchart__phase { fill: #f6f8fa; opacity: 0.55; }
.ghchart__phase–alt { fill: #ffffff; opacity: 0; }
.ghchart__phase-sep {
stroke: var(–ghc-grid); stroke-width: 1; stroke-dasharray: 2 5; opacity: 0.8;
}
.ghchart__phase-label {
fill: var(–ghc-muted);
font-size: 15px;
font-weight: 700;
letter-spacing: 0.06em;
}
.ghchart__phase-avg {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
}
/* Dashed trend line + ± noise band + slope label. */
.ghchart__trendband { opacity: 0.12; pointer-events: none; }
.ghchart__trendline {
stroke-width: 2.5;
stroke-dasharray: 8 6;
stroke-linecap: round;
fill: none;
}
.ghchart__slope {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.01em;
}
/* Horizontal reference line + label. */
.ghchart__refline {
stroke-width: 2;
stroke-dasharray: 7 6;
stroke-linecap: round;
opacity: 0.85;
}
.ghchart__reflabel {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
}
/* Data squares — values reveal on hover only. */
.ghchart__square { transition: width 0.12s ease, height 0.12s ease, x 0.12s ease, y 0.12s ease; }
.ghchart__squarehit { fill: transparent; cursor: default; outline: none; }
.ghchart__squarehit:focus-visible {
outline: 2px solid var(–ghc-axis);
outline-offset: 1px;
}
/* Invalid-run markers + leader-line annotations. */
.ghchart__invalid { stroke-width: 2; }
.ghchart__invalid-x { stroke-width: 2; stroke-linecap: round; }
.ghchart__leader { stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.8; }
.ghchart__leader–callout { stroke-dasharray: none; opacity: 0.6; }
.ghchart__anno-title {
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
}
.ghchart__anno-sub {
fill: var(–ghc-muted);
font-size: 13px;
font-weight: 500;
}
/* Final-callout labels read a touch larger than the invalid-run notes. */
.ghchart__anno-title–callout { font-size: 17px; }
.ghchart__anno-sub–callout { font-size: 14px; }
/* Bracket span annotation. */
.ghchart__bracket { stroke: var(–ghc-axis); stroke-width: 1.5; opacity: 0.75; }
.ghchart__bracket-label {
fill: var(–ghc-muted);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.04em;
}
/* Final callout ring. */
.ghchart__callout-ring { fill: none; stroke-width: 2; }
/* —————————————————————— */
/* Title + divider */
/* —————————————————————— */
.ghchart__title {
/* Matches the github.blog H2 (.wp-block-heading) exactly */
display: block;
font-size: 32px;
line-height: 40px;
font-weight: 700;
letter-spacing: -0.18px;
color: var(–ghc-text);
}
.ghchart__rule {
height: 1.5px;
border-radius: 1.5px;
background: var(–ghc-divider);
margin: 10px 0 18px;
}
/* —————————————————————— */
/* Bar chart: subtitle, panels, bars, caption */
/* —————————————————————— */
.ghchart__subtitle {
margin: -6px 0 20px;
font-size: clamp(13px, 1.6vw, 15px);
line-height: 1.5;
color: var(–ghc-muted);
}
.ghchart__panel { margin-top: 30px; }
.ghchart__panel:first-of-type { margin-top: 6px; }
.ghchart__panel-title {
margin: 0 0 6px;
font-size: clamp(15px, 1.9vw, 18px);
font-weight: 700;
letter-spacing: -0.01em;
color: var(–ghc-text);
}
.ghchart__plot–bar { touch-action: pan-y; }
.ghchart__axisline {
stroke: var(–ghc-axis);
stroke-width: 1.25;
opacity: 0.35;
}
.ghchart__bar { transition: opacity 0.12s ease; }
.ghchart__barhit { fill: transparent; }
/* Always-on value labels above selected bars. */
.ghchart__barvalue {
fill: var(–ghc-text);
font-size: 20px;
font-weight: 700;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
/* Anchor annotation: dotted reference line + floating pill. */
.ghchart__anno-line {
stroke: #8b949e;
stroke-width: 1.5;
stroke-dasharray: 4 3;
stroke-linecap: round;
pointer-events: none;
}
.ghchart__anno-chip {
fill: #ffebe9;
stroke: #ffcecb;
stroke-width: 1;
pointer-events: none;
}
.ghchart__anno-chip-text {
fill: #a0111f;
font-weight: 700;
pointer-events: none;
}
.ghchart__caption {
margin-top: 22px;
padding-top: 12px;
border-top: 1px solid var(–ghc-card-border);
font-size: 12.5px;
line-height: 1.55;
color: var(–ghc-muted);
}
.ghchart__caption b { color: var(–ghc-text); font-weight: 700; }
.ghchart__caption code {
font-family: ui-monospace, SFMono-Regular, “SF Mono”, Menlo, Consolas, monospace;
font-size: 0.92em;
padding: 1px 5px;
background: #f6f8fa;
border: 1px solid var(–ghc-card-border);
border-radius: 5px;
}
.ghchart__tag {
display: inline-block;
font-size: 10px;
font-weight: 650;
letter-spacing: 0.03em;
text-transform: uppercase;
padding: 2px 7px;
border-radius: 999px;
border: 1px solid #aceebb;
color: #0a5223;
background: #dafbe1;
white-space: nowrap;
vertical-align: middle;
}
/* —————————————————————— */
/* Distribution dashboard (segmented mix bar + ranked bar panels) */
/* —————————————————————— */
.ghchart__dist-section { margin-top: 28px; }
.ghchart__dist-section:first-of-type { margin-top: 12px; }
.ghchart–panel-cards { padding: 24px; background: var(–ghc-canvas); }
.ghchart–panel-cards .ghchart__dist-cols { gap: 24px; }
.ghchart–panel-cards .ghchart__dist-cols .ghchart__dist-section {
min-width: 0;
margin: 0;
padding: 24px;
border: 1px solid var(–ghc-card-border);
border-radius: 8px;
background: var(–ghc-card-bg);
}
@media (max-width: 480px) {
.ghchart–panel-cards,
.ghchart–panel-cards .ghchart__dist-cols .ghchart__dist-section { padding: 16px; }
}
/* Segmented proportional “mix” bar: name inside, count/percent below.
Separators are 2px inside borders (box-sizing:border-box) rather than a
flex gap, so the three segments sum to exactly 100% with no overflow. */
.ghchart__seg {
display: flex;
width: 100%;
height: 42px;
margin-top: 2px;
border-radius: 7px;
overflow: hidden;
}
.ghchart__seg-cell {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
color: #ffffff;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
}
.ghchart__seg-cell + .ghchart__seg-cell { border-left: 2px solid var(–ghc-card-bg); }
.ghchart__seg-note-row {
display: flex;
width: 100%;
margin-top: 8px;
}
.ghchart__seg-note {
min-width: 0;
text-align: center;
font-size: 12.5px;
color: var(–ghc-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* Keep the outer notes from spilling past the chart edges. */
.ghchart__seg-note:first-child { text-align: left; }
.ghchart__seg-note:last-child { text-align: right; }
.ghchart__seg-note b { color: var(–ghc-text); font-weight: 700; }
/* Ranked horizontal bar list: [category | bar track | value].
The list is the grid and rows use display:contents, so the category,
bar, and value columns line up across every row in a panel. */
.ghchart__dist-list {
display: grid;
grid-template-columns: max-content 1fr max-content;
align-items: center;
column-gap: 12px;
row-gap: 10px;
margin-top: 4px;
}
.ghchart__dist-row { display: contents; }
.ghchart__dist-cat {
text-align: right;
font-size: 13px;
line-height: 1.25;
color: var(–ghc-text);
white-space: nowrap;
}
.ghchart__dist-track { width: 100%; }
.ghchart__dist-bar {
height: 15px;
min-width: 2px;
border-radius: 3px;
}
.ghchart__dist-val {
font-size: 12.5px;
color: var(–ghc-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ghchart__dist-val b { color: var(–ghc-text); font-weight: 700; }
.ghchart__dist-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px 16px; margin: 0; }
.ghchart__dist-stat { display: flex; flex-direction: column; gap: 4px; }
.ghchart__dist-stat dt { color: var(–ghc-muted); font-size: 13px; }
.ghchart__dist-stat dd { order: -1; margin: 0; font-size: 30px; font-weight: 800; letter-spacing: -0.02em; }
.ghchart__comparison-row { display: grid; grid-template-columns: 145px minmax(0, 1fr); gap: 20px; padding: 14px 0; }
.ghchart__comparison-label { align-self: center; font-size: 15px; font-weight: 600; }
.ghchart__comparison-plot { position: relative; padding: 6px 0; display: grid; gap: 8px; }
.ghchart__comparison-grid {
position: absolute; top: 0; bottom: 0; border-left: 1px dashed var(–ghc-grid);
transform: translateX(-50%); pointer-events: none;
}
.ghchart__comparison-zero { border-left: 2px solid var(–ghc-axis); z-index: 1; }
.ghchart__comparison-track { position: relative; height: 28px; }
.ghchart__comparison-bar {
position: absolute; top: 0; height: 28px;
border-radius: 0 2px 2px 0;
pointer-events: none;
}
.ghchart__comparison-bar–negative { border-radius: 2px 0 0 2px; }
.ghchart__comparison-value {
position: absolute;
top: 4px;
z-index: 2;
width: max-content;
color: var(–ghc-text);
font-size: 13px;
font-weight: 600;
line-height: 20px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.ghchart__comparison-track–stacked .ghchart__comparison-value {
position: relative;
top: 0;
width: 100%;
white-space: normal;
overflow-wrap: anywhere;
background: var(–ghc-card-bg);
}
.ghchart__comparison-axis { position: relative; height: 24px; }
.ghchart__comparison-tick { position: absolute; transform: translateX(-50%); white-space: nowrap; font-size: 12px; color: var(–ghc-axis); }
@media (max-width: 480px) {
.ghchart__comparison-row { grid-template-columns: minmax(0, 1fr); gap: 8px; }
.ghchart__comparison-tick { font-size: 11px; }
}
/* Two-up panel grid (collapses to one column on narrow screens).
minmax(0,1fr) lets columns shrink so long labels never force overflow. */
.ghchart__dist-cols {
margin-top: 26px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px 30px;
}
.ghchart__dist-cols .ghchart__dist-section { margin-top: 0; }
.ghchart__dist-cols–stack { grid-template-columns: 1fr; }
.ghchart__dist-cols–stack .ghchart__dist-section { margin-top: 28px; }
.ghchart__dist-cols–stack .ghchart__dist-section:first-child { margin-top: 0; }
/* Tighten the label/value type a touch in the 2-up layout so the bars
keep room at the 699px blog width. */
.ghchart__dist-cols:not(.ghchart__dist-cols–stack) .ghchart__dist-cat { font-size: 12px; }
.ghchart__dist-cols:not(.ghchart__dist-cols–stack) .ghchart__dist-val { font-size: 11.5px; }
.ghchart__dist-cols:not(.ghchart__dist-cols–stack) .ghchart__dist-list { column-gap: 9px; }
@media (max-width: 600px) {
.ghchart__dist-cols { grid-template-columns: 1fr; gap: 26px; }
.ghchart__dist-cols .ghchart__dist-section ~ .ghchart__dist-section { margin-top: 2px; }
}
/* Let long category labels wrap on very narrow (mobile) widths. */
@media (max-width: 480px) {
.ghchart__dist-cat { white-space: normal; }
}
/* —————————————————————— */
/* Timeline / staged-duration chart (request lifecycle, before/after) */
/* —————————————————————— */
.ghchart__tl-eyebrow {
font-size: clamp(12px, 1.5vw, 13px);
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(–ghc-muted);
margin: 0 0 6px;
}
.ghchart__tl-headline { margin-bottom: 14px; }
/* Before -> after summary card. */
.ghchart__tl-stat {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px 24px;
padding: 16px 22px;
border: 1px solid var(–ghc-card-border);
border-radius: 12px;
/* Greenish grey to match the AI gateway diagram canvas (–ghd-canvas). */
background: #f2f5f3;
margin: 0 0 18px;
}
.ghchart__tl-stat-main { display: flex; align-items: center; gap: 20px; }
.ghchart__tl-stat-col { display: flex; flex-direction: column; gap: 2px; }
.ghchart__tl-stat-k {
font-size: 12px; font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase; color: var(–ghc-muted);
}
.ghchart__tl-stat-v {
font-size: clamp(28px, 4.4vw, 40px); font-weight: 800;
letter-spacing: -0.02em; line-height: 1; color: var(–ghc-text);
}
.ghchart__tl-stat-v–muted { color: var(–ghc-muted); font-weight: 700; }
.ghchart__tl-stat-arrow { font-size: 26px; color: var(–ghc-muted); }
.ghchart__tl-stat-delta { text-align: right; }
.ghchart__tl-stat-delta-v {
font-size: clamp(26px, 4vw, 38px); font-weight: 800;
letter-spacing: -0.02em; line-height: 1; color: #1a7f37;
}
.ghchart__tl-stat-delta-k { font-size: 13px; color: var(–ghc-muted); margin-top: 4px; }
/* Stage legend (2-up, monospace-free, stacks on mobile). */
.ghchart__tl-legend {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 28px;
margin: 0 0 20px;
}
.ghchart__tl-legend-item { display: inline-flex; align-items: center; gap: 10px; min-width: 0; }
.ghchart__tl-swatch { width: 13px; height: 13px; border-radius: 3px; flex: 0 0 auto; }
.ghchart__tl-legend-text {
font-size: clamp(13px, 1.7vw, 15px); color: var(–ghc-text);
font-variant-numeric: tabular-nums;
}
.ghchart__tl-rule {
height: 1.5px; border-radius: 1.5px; background: var(–ghc-divider); margin: 4px 0 18px;
}
/* SVG plot text. */
.ghchart__tl-rowlabel { fill: var(–ghc-text); font-size: 22px; font-weight: 700; letter-spacing: 0.02em; }
.ghchart__tl-rowsub { fill: var(–ghc-muted); font-size: 18px; font-weight: 500; }
.ghchart__tl-seg-label { fill: #ffffff; font-size: 23px; font-weight: 700; letter-spacing: -0.01em; }
.ghchart__tl-callout-line { stroke: var(–ghc-guide); stroke-width: 1.5; fill: none; }
.ghchart__tl-callout-label { font-size: 21px; font-weight: 600; letter-spacing: -0.01em; }
/* Segments + ribbons ease their opacity for the before/after hover emphasis. */
.ghchart__tl-seg { transition: opacity 0.14s ease; }
.ghchart__tl-seg[data-stage] { cursor: pointer; }
.ghchart__tl-seg[data-stage]:focus-visible { outline: 2px solid var(–ghc-axis); outline-offset: 1px; }
.ghchart__tl-flow { opacity: 0.16; transition: opacity 0.14s ease; }
/* Timeline millisecond gridlines: thicker + larger dashes so the axis marks
read clearly behind the bars (scoped so scatter gridlines are unaffected). */
.ghchart__plot–timeline .ghchart__gridline–v {
stroke-width: 1.5;
stroke-dasharray: 5 5;
opacity: 1;
}
/* Timeline bracket labels read larger than the trend bracket note (they are
structural spans, not a small secondary annotation). */
.ghchart__tl-bracket-label { font-size: 20px; }
/* Wrapper so DOM labels can sit over the SVG at viewBox-proportional %s. */
.ghchart__tl-plotwrap { position: relative; }
/* Right-bracket label as a DOM node: a fixed, readable size that does NOT
scale down with the plot on small windows (unlike SVG text). */
.ghchart__tl-bracket-label-dom {
position: absolute;
transform: translateY(-50%);
font-size: clamp(12px, 1.5vw, 14px);
font-weight: 700;
letter-spacing: 0.02em;
color: var(–ghc-muted);
white-space: nowrap;
pointer-events: none;
}
.ghchart__tl-tick { fill: var(–ghc-axis); }
/* Axis title rendered as a DOM caption below the plot; matches the legend
labels’ size/weight/color instead of scaling with the SVG. */
.ghchart__tl-axis-caption {
font-size: clamp(13px, 1.7vw, 15px);
font-weight: 400;
color: var(–ghc-text);
text-align: center;
margin-top: 8px;
}
@media (max-width: 560px) {
.ghchart__tl-legend { grid-template-columns: 1fr; }
.ghchart__tl-stat { justify-content: flex-start; }
.ghchart__tl-stat-delta { text-align: left; }
}
/* —————————————————————— */
/* Calendar heatmap (GitHub contribution-graph style) */
/* —————————————————————— */
.ghheat__eyebrow { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; }
.ghheat__eyedot {
width: 9px; height: 9px; border-radius: 50%;
background: #cf222e; box-shadow: 0 0 0 3px rgba(207, 34, 46, 0.14);
flex: 0 0 auto;
}
.ghheat__eyetext {
font-size: 12px; font-weight: 700; letter-spacing: 0.08em;
text-transform: uppercase; color: #cf222e;
}
/* Subtitle here is a prominent lede, not the muted variant. */
.ghheat__subtitle {
color: var(–ghc-text);
font-size: clamp(14px, 1.7vw, 16px);
/* 24px top gap matches the github.blog H2 → paragraph block spacing. */
margin: 24px 0 8px;
}
.ghheat__subtitle b { font-weight: 700; }
.ghheat__panel { margin-top: 36px; }
.ghheat__phead {
display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap;
margin-bottom: 16px;
}
.ghheat__badge {
display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: 50%;
color: #fff; font-size: 12px; font-weight: 700;
flex: 0 0 auto; align-self: center;
}
.ghheat__ptitle {
font-size: clamp(16px, 2vw, 19px); font-weight: 700;
letter-spacing: -0.01em; color: var(–ghc-text);
}
.ghheat__pdesc { font-size: 14px; color: var(–ghc-muted); }
.ghheat__body { display: flex; gap: 30px; align-items: flex-start; flex-wrap: wrap; }
.ghheat__gridwrap { flex: 1 1 340px; min-width: 260px; }
.ghheat__grid { display: block; width: 100%; height: auto; overflow: visible; }
.ghheat__cell {
stroke: rgba(27, 31, 36, 0.06);
stroke-width: 1;
transition: stroke 0.1s ease;
}
.ghheat__cell:hover { stroke: #24292f; stroke-width: 1.4; }
.ghheat__cell–pad { opacity: 0; pointer-events: none; }
.ghheat__wd, .ghheat__month {
fill: var(–ghc-muted); font-size: 11px; font-weight: 600;
}
.ghheat__side { flex: 1 1 250px; min-width: 230px; }
.ghheat__stat { display: flex; align-items: baseline; gap: 7px; }
.ghheat__statbig {
font-size: clamp(26px, 4vw, 34px); font-weight: 800; letter-spacing: -0.02em;
line-height: 1;
}
.ghheat__statsmall { font-size: 15px; font-weight: 600; color: var(–ghc-muted); }
.ghheat__note {
margin: 10px 0 16px; font-size: 14px; line-height: 1.55; color: var(–ghc-muted);
}
.ghheat__note b { color: var(–ghc-text); font-weight: 700; }
.ghheat__legend {
display: flex; align-items: center; gap: 5px;
font-size: 12.5px; color: var(–ghc-muted); flex-wrap: wrap;
}
.ghheat__sw { width: 12px; height: 12px; border-radius: 3px; display: inline-block; flex: 0 0 auto; }
.ghheat__sw–empty { border: 1px solid #d0d7de; }
.ghheat__legunit { margin-left: 2px; }
.ghheat__extra {
display: flex; align-items: flex-start; gap: 7px; margin-top: 14px;
font-size: 12px; line-height: 1.5; color: var(–ghc-muted); max-width: 330px;
}
.ghheat__extra .ghheat__sw { margin-top: 2px; }
/* —————————————————————— */
/* Tooltip */
/* —————————————————————— */
.ghchart__tooltip {
position: absolute;
z-index: 5;
top: 0;
left: 0;
/* `width: max-content` keeps the box sized to its own content instead of
the browser’s absolute-position “shrink-to-fit” formula, which derives
available width from how far `left` sits from the containing block’s
edge — near the right edge that shrinks toward zero and forces the
row text to wrap awkwardly even though the box is later clamped into
view. max-content is unaffected by `left`, so wrapping only happens if
content truly exceeds max-width. */
width: max-content;
min-width: 128px;
max-width: 260px;
padding: 10px 12px;
background: var(–ghc-card-bg);
border: 1px solid var(–ghc-card-border);
border-radius: 12px;
/* Layered drop shadow from Primer Brand’s ActionMenu (.ActionMenu__menu) */
box-shadow:
0px 100px 80px rgba(0, 0, 0, 0.01),
0px 41px 33px rgba(0, 0, 0, 0.02),
0px 22px 17px rgba(0, 0, 0, 0.02),
0px 12px 10px rgba(0, 0, 0, 0.03),
0px 6px 5px rgba(0, 0, 0, 0.04),
0px 2px 2px rgba(0, 0, 0, 0.07);
pointer-events: none;
opacity: 0;
transform: translate(-50%, -100%);
/* Fade in on show and out on hide; quickly ease left/top when tracking
from one point to the next. The position ease is suppressed on the
first appearance (see chart.js) so the tooltip fades in place rather
than sliding in from its previous spot. */
transition: opacity 0.12s ease,
left 0.1s cubic-bezier(0.4, 0, 0.2, 1),
top 0.1s cubic-bezier(0.4, 0, 0.2, 1);
will-change: opacity, transform, left, top;
}
.ghchart__tooltip[data-visible=”true”] { opacity: 1; }
.ghchart__tt-title {
font-size: 13px;
font-weight: 700;
color: var(–ghc-text);
margin-bottom: 6px;
}
.ghchart__tt-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
line-height: 1.6;
color: var(–ghc-muted);
}
.ghchart__tt-swatch { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; }
.ghchart__tt-name { flex: 1 1 auto; }
.ghchart__tt-value { font-weight: 700; color: var(–ghc-text); font-variant-numeric: tabular-nums; }
/* Optional “partial data” note, shown only for a hovered point past a
line’s config.partial.fromIndex (see .ghchart__line–dashed). */
.ghchart__tt-note {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(–ghc-card-border);
font-size: 12px;
font-style: italic;
color: var(–ghc-muted);
}
/* Bar tooltip body: a title line + free-form text lines (no swatches). */
.ghchart__tt-line {
font-size: 13px;
line-height: 1.5;
color: var(–ghc-muted);
}
.ghchart__tt-line:first-child { color: var(–ghc-text); font-weight: 600; }
.ghchart__tt-line + .ghchart__tt-line { margin-top: 2px; }
/* Timeline before/after tooltip: only the title is bold; both value lines stay
regular weight and the same color (no emphasised first line). */
.ghchart__tt-body–plain .ghchart__tt-line,
.ghchart__tt-body–plain .ghchart__tt-line:first-child {
font-weight: 400;
color: var(–ghc-text);
}
@media (prefers-reduced-motion: reduce) {
.ghchart__dot,
.ghchart__guide,
.ghchart__bar,
.ghchart__pt,
.ghchart__square,
.ghheat__cell,
.ghchart__tl-seg,
.ghchart__tl-flow,
.ghchart__tooltip { transition: none; }
}
/*
Shared chart engine — reusable renderer for the blog-charts library.
——————————————————————
Exposes window.GHChartEngine = { renderers, render(config, mountEl?), destroy(mountEl) }.
Renderers are keyed by config.type (currently “line”); add a new viz type
by registering GHChartEngine.renderers[] = function (root, config) {…}.
This same file is embedded verbatim at the top of each exported WordPress
chart.js, followed by that chart’s CONFIG + a small auto-boot. It has no
external dependencies and is safe to include more than once on a page.
*/
(function (global) {
“use strict”;
var SVGNS = “http://www.w3.org/2000/svg”;
function el(tag, attrs) {
var node = document.createElementNS(SVGNS, tag);
if (attrs) {
for (var k in attrs) {
if (Object.prototype.hasOwnProperty.call(attrs, k)) {
node.setAttribute(k, attrs[k]);
}
}
}
return node;
}
function html(tag, cls) {
var node = document.createElement(tag);
if (cls) node.className = cls;
return node;
}
function clamp(v, lo, hi) { return v hi ? hi : v; }
var WHITE = [255, 255, 255], BLACK = [0, 0, 0];
function hexToRgb(h) {
h = String(h).replace(“#”, “”);
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
}
function rgbToHex(c) {
return “#” + c.map(function (x) {
x = Math.max(0, Math.min(255, Math.round(x)));
return (x < 16 ? "0" : "") + x.toString(16);
}).join("");
}
function mix(a, b, t) {
return [a[0] + (b[0] – a[0]) * t, a[1] + (b[1] – a[1]) * t, a[2] + (b[2] – a[2]) * t];
}
function rampFrom(baseHex) {
var b = hexToRgb(baseHex);
return [
rgbToHex(mix(b, WHITE, 0.80)),
rgbToHex(mix(b, WHITE, 0.52)),
rgbToHex(mix(b, WHITE, 0.20)),
baseHex,
rgbToHex(mix(b, BLACK, 0.30))
];
}
function strongOf(baseHex) { return rampFrom(baseHex)[4]; }
function labelColorOn(hex) {
function luminance(color) {
var c = hexToRgb(color).map(function (channel) {
channel /= 255;
return channel = 4.5) return “#1f2328”;
return 1.05 / (background + 0.05) >= 4.5 ? “#ffffff” : “#000000”;
}
var renderers = {};
var renderCleanups = (global.GHChartEngine && global.GHChartEngine._renderCleanups) || new WeakMap();
renderers.comparison = function (root, config) {
var axis = config.xAxis, series = config.series, rows = config.rows;
if (!axis || !Number.isFinite(axis.min) || !Number.isFinite(axis.max) ||
axis.min > 0 || axis.max < 0 || axis.max <= axis.min ||
!Array.isArray(axis.ticks) || !Array.isArray(series) || !series.length ||
series.some(function (s) { return !s || typeof s.color !== "string" || !/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(s.color); }) ||
!Array.isArray(rows) || !rows.length ||
axis.ticks.some(function (v) { return !Number.isFinite(v) || v axis.max; }) ||
rows.some(function (row) {
return !Array.isArray(row.values) || row.values.length !== series.length ||
row.values.some(function (v) { return !Number.isFinite(v) || v axis.max; }) ||
!Array.isArray(row.decimals) || row.decimals.length !== series.length ||
row.decimals.some(function (n) { return !Number.isInteger(n) || n 10; });
})) throw new Error(“Comparison chart requires a zero-inclusive axis, hex series colors, and matching finite values/decimal precision for each series.”);
root.textContent = “”;
root.setAttribute(“role”, “figure”);
root.setAttribute(“aria-label”, config.title);
var heading = html(“div”, “ghchart__headline”);
heading.textContent = config.title;
root.appendChild(heading);
if (config.subtitle) {
var subtitle = html(“p”, “ghchart__subtitle”);
subtitle.textContent = config.subtitle;
root.appendChild(subtitle);
}
var legend = html(“div”, “ghchart__legend ghchart__legend–trend”);
series.forEach(function (s) {
var item = html(“span”, “ghchart__legend-item”);
var swatch = html(“span”, “ghchart__swatch”);
swatch.style.background = s.color;
item.appendChild(swatch);
item.appendChild(document.createTextNode(s.name));
legend.appendChild(item);
});
root.appendChild(legend);
var marks = [];
function at(value) { return (value – axis.min) / (axis.max – axis.min) * 100; }
function signed(value, decimals) { return (value > 0 ? “+” : “”) + value.toFixed(decimals) + “%”; }
rows.forEach(function (row) {
var group = html(“div”, “ghchart__comparison-row”);
group.setAttribute(“role”, “group”);
group.setAttribute(“aria-label”, row.label);
var label = html(“div”, “ghchart__comparison-label”);
label.textContent = row.label;
label.setAttribute(“aria-hidden”, “true”);
group.appendChild(label);
var plot = html(“div”, “ghchart__comparison-plot”);
axis.ticks.forEach(function (value) {
var line = html(“span”, “ghchart__comparison-grid”);
line.style.left = at(value) + “%”;
line.setAttribute(“aria-hidden”, “true”);
plot.appendChild(line);
});
var zero = html(“span”, “ghchart__comparison-grid ghchart__comparison-zero”);
zero.style.left = at(0) + “%”;
zero.setAttribute(“aria-hidden”, “true”);
plot.appendChild(zero);
series.forEach(function (s, index) {
var value = row.values[index], formatted = signed(value, row.decimals[index]);
var track = html(“div”, “ghchart__comparison-track”);
track.setAttribute(“role”, “group”);
track.setAttribute(“aria-label”, s.name);
var bar = html(“span”, “ghchart__comparison-bar”);
// Anchor the zero-facing edge, without fractional transform snapping.
if (value < 0) {
bar.style.right = "calc(100% – " + at(0) + "%)";
bar.classList.add("ghchart__comparison-bar–negative");
} else bar.style.left = at(0) + "%";
bar.style.width = Math.abs(at(value) – at(0)) + "%";
bar.style.background = s.color;
bar.setAttribute("aria-hidden", "true");
var valueLabel = html("span", "ghchart__comparison-value");
valueLabel.textContent = formatted;
track.appendChild(bar);
track.appendChild(valueLabel);
marks.push({ track: track, bar: bar, label: valueLabel, value: value, color: s.color });
plot.appendChild(track);
});
group.appendChild(plot);
root.appendChild(group);
});
var axisRow = html("div", "ghchart__comparison-row");
axisRow.appendChild(html("span"));
var ticks = html("div", "ghchart__comparison-axis");
axis.ticks.forEach(function (value) {
var tick = html("span", "ghchart__comparison-tick");
tick.style.left = at(value) + "%";
if (value === axis.min) tick.style.transform = "none";
if (value === axis.max) tick.style.transform = "translateX(-100%)";
tick.textContent = value === 0 ? "0" : signed(value, 0);
ticks.appendChild(tick);
});
axisRow.appendChild(ticks);
root.appendChild(axisRow);
var active = true, frame = 0, observer;
function positionLabel(mark, side, coordinate, placement) {
mark.label.style.left = side === "left" ? "calc(" + at(coordinate) + "% + 8px)" : "auto";
mark.label.style.right = side === "right" ? "calc(100% – " + at(coordinate) + "% + 8px)" : "auto";
mark.label.style.color = placement === "inside" ? labelColorOn(mark.color) : "";
mark.label.setAttribute("data-placement", placement);
}
function layoutValues() {
if (!active || !root.clientWidth) return;
marks.forEach(function (mark) {
mark.track.classList.remove("ghchart__comparison-track–stacked");
mark.track.style.height = "";
mark.bar.style.top = "";
mark.label.style.left = "0px";
mark.label.style.right = "auto";
mark.label.style.color = "";
var width = mark.track.clientWidth, textWidth = mark.label.offsetWidth + 1;
var zero = at(0) * width / 100, end = at(mark.value) * width / 100;
var negative = mark.value = textWidth + 8) {
positionLabel(mark, negative ? “right” : “left”, mark.value, “outside”);
} else if (Math.abs(end – zero) >= textWidth + 16) {
positionLabel(mark, negative ? “left” : “right”, mark.value, “inside”);
} else if (opposite >= textWidth + 8) {
positionLabel(mark, negative ? “left” : “right”, 0, “opposite”);
} else {
// Very narrow plots or long precision strings get a separate label line.
mark.track.classList.add(“ghchart__comparison-track–stacked”);
mark.label.setAttribute(“data-placement”, “stacked”);
var top = mark.label.offsetHeight + 6;
mark.bar.style.top = top + “px”;
mark.track.style.height = top + 28 + “px”;
}
});
}
function schedule() {
if (!active) return;
cancelAnimationFrame(frame);
frame = requestAnimationFrame(layoutValues);
}
layoutValues();
if (global.ResizeObserver) {
observer = new ResizeObserver(schedule);
observer.observe(root);
} else global.addEventListener(“resize”, schedule);
if (document.fonts) {
document.fonts.ready.then(schedule);
document.fonts.addEventListener(“loadingdone”, schedule);
}
return function () {
active = false;
cancelAnimationFrame(frame);
if (observer) observer.disconnect();
global.removeEventListener(“resize”, schedule);
if (document.fonts) document.fonts.removeEventListener(“loadingdone”, schedule);
};
};
/* ================================================================
Line chart renderer
================================================================ */
renderers.line = function (root, config) {
// viewBox geometry (design units; scales fluidly to any width).
var VB = { w: 1000, h: 560 };
var M = { top: 30, right: 34, bottom: 56, left: 86 };
// Inset the plotted points slightly from the axis so the first/last
// months (e.g. “Jan”) don’t crowd the y-axis labels (e.g. “50”).
var PAD_X = 18;
// Reset (in case of re-render) and use a unique id prefix so multiple
// charts on one page never share gradient ids.
root.textContent = “”;
var uid = config.mountId;
var n = config.labels.length;
var yMin = config.yAxis.min;
var yMax = config.yAxis.max;
var step = config.yAxis.step;
// Optional log y (yAxis.scale: “log”) for data spanning many decades;
// ticks are then explicit (yAxis.ticks), e.g. 1, 10, 100 … 1B.
var isLog = config.yAxis.scale === “log”;
var yTicks = config.yAxis.ticks;
if (!yTicks) {
yTicks = [];
for (var tv0 = yMin; tv0 <= yMax + 0.0001; tv0 += step) yTicks.push(tv0);
}
// Y-axis tick labels can format differently from the tooltip values
// (e.g. a plain "40" on the axis but "40%" in the tooltip).
var fmtAxis = config.formatAxis || config.formatValue;
// Widen the left gutter when the formatted tick labels need it (a unit
// suffix like "2,000M" is far wider than a bare "40"). Labels are real
// SVG we can’t measure before layout, so estimate from the
// glyph mix at the 22-unit axis font size.
var AXIS_FONT = 22;
var Y_LABEL_GAP = 22;
function estimateTextW(s) {
var w = 0;
for (var i = 0; i < s.length; i++) {
var c = s.charAt(i);
if (c === "," || c === "." || c === " ") w += 0.28;
else if (/[0-9]/.test(c)) w += 0.58;
else if (/[A-Z]/.test(c)) w += 0.72;
else w += 0.55;
}
return w * AXIS_FONT;
}
var widestY = 0;
yTicks.forEach(function (tv) {
widestY = Math.max(widestY, estimateTextW(String(fmtAxis(tv))));
});
// A rotated y-axis title (yAxis.title) needs its own strip of gutter.
var Y_TITLE_W = config.yAxis.title ? 40 : 0;
M.left = Math.max(M.left, Math.ceil(widestY + Y_LABEL_GAP + 8 + Y_TITLE_W));
var innerW = VB.w – M.left – M.right;
var innerH = VB.h – M.top – M.bottom;
var plotW = innerW – 2 * PAD_X;
var stepX = n <= 1 ? 0 : plotW / (n – 1);
function xAt(i) { return n cubic Bezier) path builder.
// config.curve: “linear” draws straight segments — for discrete
// categories (e.g. percentiles) where a curve would imply values
// between the points.
function smooth(points) {
if (!points.length) return “”;
if (points.length < 2) return "M" + points[0].x + "," + points[0].y;
if (config.curve === "linear") {
return points.map(function (p, i) {
return (i ? "L " : "M ") + p.x + "," + p.y;
}).join(" ");
}
var d = "M " + points[0].x + "," + points[0].y;
for (var i = 0; i < points.length – 1; i++) {
var p0 = points[i – 1] || points[i];
var p1 = points[i];
var p2 = points[i + 1];
var p3 = points[i + 2] || p2;
var t = 1 / 6;
var c1x = p1.x + (p2.x – p0.x) * t;
var c1y = p1.y + (p2.y – p0.y) * t;
var c2x = p2.x – (p3.x – p1.x) * t;
var c2y = p2.y – (p3.y – p1.y) * t;
d += " C " + c1x + "," + c1y + " " + c2x + "," + c2y + " " + p2.x + "," + p2.y;
}
return d;
}
// Area fills (drawn first, behind lines). When config.partial marks a
// trailing partial/incomplete range (e.g. a month still in progress),
// the fill stops at the last complete point instead of extending under
// the partial segment — visually distinguishing "real" coverage from
// data that isn't final yet.
var partialIdx = config.partial ? config.partial.fromIndex : null;
config.series.forEach(function (s, si) {
if (!s.area) return;
var p = pts[si];
var fillP = partialIdx == null ? p : p.slice(0, partialIdx + 1);
if (fillP.length < 2) return;
var d = smooth(fillP) + " L " + fillP[fillP.length – 1].x + "," + baseY +
" L " + fillP[0].x + "," + baseY + " Z";
svg.appendChild(el("path", { d: d, fill: "url(#" + uid + "-grad-" + si + ")" }));
});
// Vertical hover guide (hidden until hover).
var guide = el("line", {
"class": "ghchart__guide",
x1: xAt(0), y1: M.top – 6, x2: xAt(0), y2: baseY
});
svg.appendChild(guide);
// Lines (draw in reverse so the first series sits on top). The trailing
// partial segment (from the last complete point onward) renders as its
// own dashed path so the "not final yet" range reads clearly without
// a legend entry — mirroring the projected-region convention above.
config.series.slice().reverse().forEach(function (s) {
var si = config.series.indexOf(s);
var p = pts[si];
var solidP = partialIdx == null ? p : p.slice(0, partialIdx + 1);
svg.appendChild(el("path", {
"class": "ghchart__line",
d: smooth(solidP),
stroke: s.color,
"stroke-width": s.width || (si === 0 ? 4 : 3.25)
}));
if (partialIdx != null && partialIdx < p.length – 1) {
// Straight (not curve-fit) dashed segment: fitting a Catmull-Rom
// curve to just this short tail would use different neighbor
// context than the solid curve and create a visible kink at the
// junction point; a straight dashed line reads as "projected" and
// joins the solid curve's end point cleanly.
var dashedP = p.slice(partialIdx);
var dd = "M " + dashedP[0].x + "," + dashedP[0].y;
var segLen = 0;
for (var di = 1; di 0 && segLen / PERIOD partialIdx) ? “” : “none”;
}
// Anchor above the highest point in this column.
var topSi = 0, topY = Infinity;
config.series.forEach(function (s, si) {
var yy = yAt(s.values[i]);
if (yy < topY) { topY = yy; topSi = si; }
});
var anchor = dotEls[topSi][i].getBoundingClientRect();
var rootRect = root.getBoundingClientRect();
var left = anchor.left + anchor.width / 2 – rootRect.left;
var top = anchor.top – rootRect.top – 12;
// On the first appearance, fade in exactly at the point: suppress the
// position ease so it doesn't slide in from its previous resting spot.
var suppress = reappearing && !reduceMotion();
if (suppress) tip.style.transition = "opacity 0.12s ease";
tip.setAttribute("data-visible", "true");
// Reset left before measuring: a browser sizes an absolutely
// positioned auto/max-content-width box using the *current* `left`
// to derive available space, so measuring while `left` still holds
// the previous (possibly near-edge) position creates a feedback loop
// that keeps squeezing the tooltip narrower and wrapping its rows.
// Setting left to 0 first guarantees a true, unconstrained natural
// width; this happens synchronously before the next paint, so
// there's no visible flash.
tip.style.left = "0px";
// Clamp horizontally within the container.
var halfW = tip.offsetWidth / 2;
left = clamp(left, halfW + 2, root.clientWidth – halfW – 2);
// Flip below the point if there's no room above.
if (top – tip.offsetHeight < 0) {
top = anchor.bottom – rootRect.top + 12;
tip.style.transform = "translate(-50%, 0)";
} else {
tip.style.transform = "translate(-50%, -100%)";
}
tip.style.left = left + "px";
tip.style.top = top + "px";
// Commit that position without animating, then restore the CSS
// transition so subsequent point-to-point moves ease into place.
if (suppress) {
void tip.offsetWidth;
tip.style.transition = "";
}
}
function hide() {
activeIndex = -1;
guide.style.opacity = "0";
tip.setAttribute("data-visible", "false");
dotEls.forEach(function (col, si) {
col.forEach(function (c) { c.setAttribute("r", baseR[si]); });
});
}
hit.addEventListener("pointermove", function (e) { show(indexFromClientX(e.clientX)); });
hit.addEventListener("pointerdown", function (e) { show(indexFromClientX(e.clientX)); });
hit.addEventListener("pointerleave", hide);
// Keyboard access.
svg.setAttribute("tabindex", "0");
svg.addEventListener("keydown", function (e) {
if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
var start = activeIndex { title, lines:[…] } }
No printed value labels — values surface on hover (consistent with
the line chart). Bars use a single tunable color with rounded tops.
================================================================ */
renderers.bar = function (root, config) {
root.textContent = “”;
var barColor = (config.series && config.series[0] && config.series[0].color) || “#ff7b72”;
var barRadius = config.barRadius != null ? config.barRadius : 8;
// Per-bar fill: a bar may point at a different series (e.g. a muted
// “preliminary” color) via panel.barSeries[index]; otherwise series[0].
function barColorFor(panel, j) {
var si = (panel.barSeries && panel.barSeries[j] != null) ? panel.barSeries[j] : 0;
var s = config.series && config.series[si];
return (s && s.color) || barColor;
}
var motionMQ = window.matchMedia
? window.matchMedia(“(prefers-reduced-motion: reduce)”)
: null;
function reduceMotion() { return !!(motionMQ && motionMQ.matches); }
/* —- Title + divider (+ optional subtitle) —————— */
var title = html(“span”, “ghchart__title”);
title.textContent = config.title;
root.appendChild(title);
root.appendChild(html(“div”, “ghchart__rule”));
if (config.subtitle) {
var sub = html(“p”, “ghchart__subtitle”);
sub.textContent = config.subtitle;
root.appendChild(sub);
}
/* —- One shared tooltip for all panels ———————- */
var tip = html(“div”, “ghchart__tooltip”);
tip.setAttribute(“role”, “status”);
var tipTitle = html(“div”, “ghchart__tt-title”);
var tipBody = html(“div”, “ghchart__tt-body”);
tip.appendChild(tipTitle);
tip.appendChild(tipBody);
root.appendChild(tip);
var allBars = []; // { el, pi, bi }
var activeKey = null; // “pi:bi” of the hovered bar
function hideTip() {
activeKey = null;
tip.setAttribute(“data-visible”, “false”);
allBars.forEach(function (b) { b.el.style.opacity = “1”; });
}
// Hoisted: referenced by the per-column pointer handlers below.
function showTip(pi, bi, panel, panelBars) {
var reappearing = activeKey === null;
activeKey = pi + “:” + bi;
allBars.forEach(function (b) {
b.el.style.opacity = (b.pi === pi && b.bi === bi) ? “1” : “0.42”;
});
var content = panel.tooltip
? panel.tooltip(bi, panel)
: { title: (panel.labels || [])[bi],
lines: [ (panel.formatValue || String)(panel.values[bi]) ] };
tipTitle.textContent = content.title != null ? content.title : “”;
tipBody.textContent = “”;
(content.lines || []).forEach(function (ln) {
var row = html(“div”, “ghchart__tt-line”);
row.textContent = ln;
tipBody.appendChild(row);
});
var anchor = panelBars[bi].el.getBoundingClientRect();
var rootRect = root.getBoundingClientRect();
var left = anchor.left + anchor.width / 2 – rootRect.left;
var top = anchor.top – rootRect.top – 12;
var suppress = reappearing && !reduceMotion();
if (suppress) tip.style.transition = “opacity 0.12s ease”;
tip.setAttribute(“data-visible”, “true”);
var halfW = tip.offsetWidth / 2;
left = clamp(left, halfW + 2, root.clientWidth – halfW – 2);
if (top – tip.offsetHeight 0) {
for (var v = yMin; v <= yMax + 1e-9; v += step) {
var gy = yAt(v);
svg.appendChild(el("line", {
"class": "ghchart__gridline", x1: M.left, y1: gy, x2: VB.w – M.right, y2: gy
}));
var yl = el("text", {
"class": "ghchart__axislabel ghchart__axislabel–y",
x: M.left – 18, y: gy, dy: "0.32em", "text-anchor": "end"
});
yl.textContent = fmt(v);
svg.appendChild(yl);
}
}
// Solid baseline.
svg.appendChild(el("line", {
"class": "ghchart__axisline", x1: M.left, y1: baseY, x2: VB.w – M.right, y2: baseY
}));
// x-axis labels.
for (var i = 0; i < nb; i++) {
var xl = el("text", {
"class": "ghchart__axislabel ghchart__axislabel–x",
x: xCenter(i), y: VB.h – M.bottom + 30, "text-anchor": "middle"
});
if (panel.xLabelSize) xl.setAttribute("style", "font-size:" + panel.xLabelSize + "px");
xl.textContent = labels[i];
svg.appendChild(xl);
}
// Bars (rounded top corners only) + full-height hover columns.
var panelBars = [];
for (var j = 0; j dark ramp is derived from a single tunable base color.
================================================================ */
renderers.heatmap = function (root, config) {
root.textContent = “”;
function trim0(s) { return s.replace(/.0$/, “”); }
function fmtM(v) {
if (v >= 1000) { var g = v / 1000; return (g >= 10 ? Math.round(g) : trim0(g.toFixed(1))) + “B”; }
if (v >= 1) return trim0(v.toFixed(1)) + “M”;
return Math.round(v * 1000) + “K”;
}
var MON = [“Jan”, “Feb”, “Mar”, “Apr”, “May”, “Jun”, “Jul”, “Aug”, “Sep”, “Oct”, “Nov”, “Dec”];
var MON_FULL = [“January”, “February”, “March”, “April”, “May”, “June”,
“July”, “August”, “September”, “October”, “November”, “December”];
var WD = [“Sun”, “Mon”, “Tue”, “Wed”, “Thu”, “Fri”, “Sat”];
var year = config.year || 2026;
var startM = config.startMonth != null ? config.startMonth : 0;
var endM = config.endMonth != null ? config.endMonth : 5;
var emptyColor = config.emptyColor || “#ebedf0”;
function daysInMonth(y, m) { return new Date(y, m + 1, 0).getDate(); }
function parseISO(s) { var p = s.split(“-“); return new Date(+p[0], +p[1] – 1, +p[2]); }
function dayStart(dt) { return new Date(dt.getFullYear(), dt.getMonth(), dt.getDate()); }
/* —- Eyebrow + title + subtitle —————————– */
if (config.eyebrow) {
var eb = html(“div”, “ghheat__eyebrow”);
eb.appendChild(html(“span”, “ghheat__eyedot”));
var ebt = html(“span”, “ghheat__eyetext”);
ebt.textContent = config.eyebrow;
eb.appendChild(ebt);
root.appendChild(eb);
}
var title = html(“span”, “ghchart__title”);
title.textContent = config.title;
root.appendChild(title);
if (config.subtitle) {
var sub = html(“p”, “ghchart__subtitle ghheat__subtitle”);
sub.innerHTML = config.subtitle;
root.appendChild(sub);
}
/* —- One shared tooltip ————————————– */
var tip = html(“div”, “ghchart__tooltip”);
tip.setAttribute(“role”, “status”);
var tipTitle = html(“div”, “ghchart__tt-title”);
var tipBody = html(“div”, “ghchart__tt-body”);
tip.appendChild(tipTitle);
tip.appendChild(tipBody);
root.appendChild(tip);
var motionMQ = window.matchMedia ? window.matchMedia(“(prefers-reduced-motion: reduce)”) : null;
function reduceMotion() { return !!(motionMQ && motionMQ.matches); }
var activeCell = null;
function hideTip() { activeCell = null; tip.setAttribute(“data-visible”, “false”); }
function showTip(rectEl, titleText, lines) {
var reappearing = activeCell === null;
activeCell = rectEl;
tipTitle.textContent = titleText;
tipBody.textContent = “”;
lines.forEach(function (ln) {
var r = html(“div”, “ghchart__tt-line”);
r.innerHTML = ln;
tipBody.appendChild(r);
});
var a = rectEl.getBoundingClientRect(), rr = root.getBoundingClientRect();
var left = a.left + a.width / 2 – rr.left, top = a.top – rr.top – 12;
var suppress = reappearing && !reduceMotion();
if (suppress) tip.style.transition = “opacity 0.12s ease”;
tip.setAttribute(“data-visible”, “true”);
var halfW = tip.offsetWidth / 2;
left = clamp(left, halfW + 2, root.clientWidth – halfW – 2);
if (top – tip.offsetHeight < 0) {
top = a.bottom – rr.top + 12;
tip.style.transform = "translate(-50%, 0)";
} else {
tip.style.transform = "translate(-50%, -100%)";
}
tip.style.left = left + "px";
tip.style.top = top + "px";
if (suppress) { void tip.offsetWidth; tip.style.transition = ""; }
}
/* —- Panels ————————————————– */
(config.panels || []).forEach(function (panel, pi) {
var base = (config.series && config.series[pi] && config.series[pi].color) || panel.base || "#3094ff";
var ramp = rampFrom(base);
var strong = ramp[4];
var totals = panel.months || [];
var activeVals = [];
for (var mi = startM; mi <= endM; mi++) { if (totals[mi] != null) activeVals.push(totals[mi]); }
var vmin = activeVals.length ? Math.min.apply(null, activeVals) : 0;
var vmax = activeVals.length ? Math.max.apply(null, activeVals) : 1;
function levelFor(t) {
if (t == null) return -1;
if (vmax === vmin) return 4;
return Math.round((t – vmin) / (vmax – vmin) * 4);
}
var af = panel.activeFrom ? parseISO(panel.activeFrom) : new Date(year, startM, 1);
var at = panel.activeTo ? parseISO(panel.activeTo) : new Date(year, endM, daysInMonth(year, endM));
var wrap = html("div", "ghheat__panel");
var head = html("div", "ghheat__phead");
var badge = html("span", "ghheat__badge");
badge.textContent = panel.badge != null ? panel.badge : (pi + 1);
badge.style.background = strong;
head.appendChild(badge);
var pt = html("span", "ghheat__ptitle");
pt.textContent = panel.title;
head.appendChild(pt);
if (panel.descriptor) {
var pd = html("span", "ghheat__pdesc");
pd.textContent = panel.descriptor;
head.appendChild(pd);
}
wrap.appendChild(head);
var body = html("div", "ghheat__body");
/* —- Calendar grid (SVG) —- */
var gridStart = new Date(year, startM, 1);
var gs = new Date(gridStart);
gs.setDate(gs.getDate() – gs.getDay()); // back to Sunday
var gridEnd = new Date(year, endM, daysInMonth(year, endM));
var ge = new Date(gridEnd);
ge.setDate(ge.getDate() + (6 – ge.getDay())); // forward to Saturday
var nCols = Math.round((ge – gs) / (7 * 86400000)) + 1;
var cell = 13, sq = 11, rad = 2.5, labL = 34, labT = 18;
var W = labL + nCols * cell, H = labT + 7 * cell;
var svg = el("svg", {
"class": "ghheat__grid",
viewBox: "0 0 " + W + " " + H,
preserveAspectRatio: "xMinYMin meet",
role: "img",
"aria-label": panel.title + " calendar heatmap"
});
[1, 3, 5].forEach(function (r) {
var t = el("text", {
"class": "ghheat__wd", x: labL – 6, y: labT + r * cell + sq / 2,
dy: "0.32em", "text-anchor": "end"
});
t.textContent = WD[r];
svg.appendChild(t);
});
// Month labels above the column where each month begins.
for (var lm = startM; lm <= endM; lm++) {
var first = new Date(year, lm, 1);
var colOf = Math.floor((dayStart(first) – gs) / (7 * 86400000));
var mlab = el("text", { "class": "ghheat__month", x: labL + colOf * cell, y: 12, "text-anchor": "start" });
mlab.textContent = MON[lm];
svg.appendChild(mlab);
}
var afS = dayStart(af), atS = dayStart(at);
var d = new Date(gs);
for (var i = 0; i = startM && m = afS && ds = 0 ? ramp[lvl] : emptyColor
});
if (inRange) {
(function (dt, mm, has) {
var dateStr = WD[dt.getDay()] + ” u00b7 ” + MON[mm] + ” ” + dt.getDate() + “, ” + year;
var lines;
if (has) {
var daily = totals[mm] / daysInMonth(year, mm);
lines = [
“” + fmtM(daily) + “ ” + panel.legendLabel,
MON_FULL[mm] + ” u00b7 ” + fmtM(totals[mm]) + ” ” + panel.unitTotal
];
} else {
lines = [panel.emptyNote || “No vetted data”];
}
cellEl.addEventListener(“pointermove”, function () { showTip(cellEl, dateStr, lines); });
cellEl.addEventListener(“pointerdown”, function () { showTip(cellEl, dateStr, lines); });
cellEl.addEventListener(“pointerleave”, hideTip);
})(new Date(d), m, hasData);
} else {
cellEl.setAttribute(“class”, “ghheat__cell ghheat__cell–pad”);
}
svg.appendChild(cellEl);
d.setDate(d.getDate() + 1);
}
var gridwrap = html(“div”, “ghheat__gridwrap”);
gridwrap.appendChild(svg);
body.appendChild(gridwrap);
/* —- Side column: stat + note + legend + extra —- */
var side = html(“div”, “ghheat__side”);
if (panel.stat) {
var stat = html(“div”, “ghheat__stat”);
var big = html(“span”, “ghheat__statbig”);
big.textContent = panel.stat.big;
big.style.color = strong;
stat.appendChild(big);
if (panel.stat.small) {
var small = html(“span”, “ghheat__statsmall”);
small.textContent = panel.stat.small;
stat.appendChild(small);
}
side.appendChild(stat);
}
if (panel.note) {
var note = html(“p”, “ghheat__note”);
note.innerHTML = panel.note;
side.appendChild(note);
}
var leg = html(“div”, “ghheat__legend”);
var lw1 = html(“span”, “ghheat__legword”); lw1.textContent = “Fewer”; leg.appendChild(lw1);
ramp.forEach(function (c) {
var s = html(“span”, “ghheat__sw”); s.style.background = c; leg.appendChild(s);
});
var lw2 = html(“span”, “ghheat__legword”); lw2.textContent = “More”; leg.appendChild(lw2);
if (panel.legendLabel) {
var lu = html(“span”, “ghheat__legunit”); lu.textContent = “u00b7 ” + panel.legendLabel; leg.appendChild(lu);
}
side.appendChild(leg);
if (panel.extraNote) {
var ex = html(“div”, “ghheat__extra”);
var exsw = html(“span”, “ghheat__sw ghheat__sw–empty”);
exsw.style.background = emptyColor;
ex.appendChild(exsw);
var ext = html(“span”, “ghheat__extratext”);
ext.innerHTML = panel.extraNote;
ex.appendChild(ext);
side.appendChild(ex);
}
body.appendChild(side);
wrap.appendChild(body);
root.appendChild(wrap);
});
if (config.caption) {
var cap = html(“div”, “ghchart__caption”);
cap.innerHTML = config.caption;
root.appendChild(cap);
}
};
/* ================================================================
Scatter renderer — X/Y numeric plot with categorical points.
Used for cost-vs-quality benchmark charts: a highlighted “primary”
point (glow halo), a “reference” point with a dotted crosshair, and
muted “other” points. Values never print on the marks — a point’s
name shows for identity and the exact (y, x) reveals on hover.
================================================================ */
renderers.scatter = function (root, config) {
root.textContent = “”;
var uid = config.mountId;
var VB = { w: 1000, h: 600 };
var M = { top: 40, right: 30, bottom: 74, left: 96 };
var innerW = VB.w – M.left – M.right;
var innerH = VB.h – M.top – M.bottom;
var xa = config.xAxis, ya = config.yAxis;
var fmtX = xa.format || function (v) { return String(v); };
var fmtY = ya.format || function (v) { return String(v); };
var tipSuffix = config.tipSuffix || “”;
function xAt(v) { return M.left + innerW * (v – xa.min) / (xa.max – xa.min); }
function yAt(v) { return M.top + innerH * (1 – (v – ya.min) / (ya.max – ya.min)); }
var baseY = yAt(ya.min);
var motionMQ = window.matchMedia
? window.matchMedia(“(prefers-reduced-motion: reduce)”)
: null;
function reduceMotion() { return !!(motionMQ && motionMQ.matches); }
function colorOf(pt) {
var s = config.series && config.series[pt.series || 0];
return (s && s.color) || “#8c959f”;
}
/* —- Title (compact narrative headline, no rule) ———— */
var title = html(“div”, “ghchart__headline”);
title.textContent = config.title;
root.appendChild(title);
/* —- Header + legend (from series) ————————– */
if (!config.hideLegend) {
var header = html(“div”, “ghchart__header”);
var legend = html(“div”, “ghchart__legend”);
(config.series || []).forEach(function (s) {
var item = html(“span”, “ghchart__legend-item”);
var sw = html(“span”, “ghchart__swatch ghchart__swatch–dot”);
sw.style.background = s.color;
item.appendChild(sw);
item.appendChild(document.createTextNode(s.name));
legend.appendChild(item);
});
header.appendChild(legend);
root.appendChild(header);
}
/* —- SVG plot ———————————————– */
var svg = el(“svg”, {
“class”: “ghchart__plot ghchart__plot–scatter”,
viewBox: “0 0 ” + VB.w + ” ” + VB.h,
preserveAspectRatio: “xMidYMid meet”,
role: “img”,
“aria-label”: config.title + ” scatter plot”
});
// Gridlines (both directions) + tick labels.
var eps = 1e-6;
for (var yv = ya.min; yv <= ya.max + eps; yv += ya.step) {
var gy = yAt(yv);
svg.appendChild(el("line", {
"class": "ghchart__gridline",
x1: M.left, y1: gy, x2: VB.w – M.right, y2: gy
}));
var yl = el("text", {
"class": "ghchart__axislabel ghchart__axislabel–y",
x: M.left – 16, y: gy, dy: "0.32em", "text-anchor": "end"
});
yl.textContent = fmtY(yv);
svg.appendChild(yl);
}
for (var xv = xa.min; xv <= xa.max + eps; xv += xa.step) {
var gx = xAt(xv);
svg.appendChild(el("line", {
"class": "ghchart__gridline ghchart__gridline–v",
x1: gx, y1: M.top, x2: gx, y2: baseY
}));
var isFirst = Math.abs(xv – xa.min) < eps;
var isLast = Math.abs(xv – xa.max) < eps;
var xl = el("text", {
"class": "ghchart__axislabel ghchart__axislabel–x",
x: gx, y: baseY + 34,
"text-anchor": isFirst ? "start" : (isLast ? "end" : "middle")
});
xl.textContent = fmtX(xv);
svg.appendChild(xl);
}
// Axis titles.
if (xa.title) {
var xt = el("text", {
"class": "ghchart__axistitle",
x: M.left + innerW / 2, y: VB.h – 12, "text-anchor": "middle"
});
xt.textContent = xa.title;
svg.appendChild(xt);
}
if (ya.title) {
var ytX = 24, ytY = M.top + innerH / 2;
var yt = el("text", {
"class": "ghchart__axistitle",
x: ytX, y: ytY, "text-anchor": "middle",
transform: "rotate(-90 " + ytX + " " + ytY + ")"
});
yt.textContent = ya.title;
svg.appendChild(yt);
}
// Reference crosshair through the flagged point (dual dotted lines).
var refPt = (config.points || []).filter(function (p) { return p.reference; })[0];
if (refPt) {
var rColor = colorOf(refPt);
var rx = xAt(refPt.x), ry = yAt(refPt.y);
svg.appendChild(el("line", {
"class": "ghchart__crosshair",
x1: rx, y1: M.top, x2: rx, y2: baseY, stroke: rColor
}));
svg.appendChild(el("line", {
"class": "ghchart__crosshair",
x1: M.left, y1: ry, x2: VB.w – M.right, y2: ry, stroke: rColor
}));
}
// Points: halo (highlight/reference) + dot + name label.
var dotEls = [], hitEls = [];
(config.points || []).forEach(function (pt, i) {
var cx = xAt(pt.x), cy = yAt(pt.y), c = colorOf(pt);
var big = pt.highlight || pt.reference;
if (big) {
svg.appendChild(el("circle", {
"class": "ghchart__halo", cx: cx, cy: cy, r: 17, fill: c
}));
}
var dot = el("circle", {
"class": "ghchart__pt", cx: cx, cy: cy,
r: big ? 9 : 5.5, fill: c
});
svg.appendChild(dot);
dotEls.push({ el: dot, baseR: big ? 9 : 5.5 });
// Name label — bold+colored for the story points, muted for the rest.
var pos = pt.labelPos || (pt.highlight ? "below" : pt.reference ? "above" : "right");
var dx = 0, dy = 0, anchor = "middle";
var gap = big ? 15 : 9;
if (pos === "right") { dx = gap + 4; dy = 5; anchor = "start"; }
else if (pos === "left") { dx = -(gap + 4); dy = 5; anchor = "end"; }
else if (pos === "above") { dy = -(gap + 6); anchor = "middle"; }
else { dy = gap + 16; anchor = "middle"; } // below
var lbl = el("text", {
"class": "ghchart__pt-label" + (big ? " ghchart__pt-label–strong" : ""),
x: cx + dx, y: cy + dy, "text-anchor": anchor,
fill: big ? c : null
});
lbl.textContent = pt.label;
svg.appendChild(lbl);
// Transparent hit target for hover/focus.
var hit = el("circle", {
"class": "ghchart__pthit", cx: cx, cy: cy, r: 20,
tabindex: "0", role: "img",
"aria-label": pt.label + ": " + fmtY(pt.y) + ", " + fmtX(pt.x) + tipSuffix
});
svg.appendChild(hit);
hitEls.push(hit);
});
root.appendChild(svg);
/* —- Tooltip (title + free lines) ————————— */
var tip = html("div", "ghchart__tooltip");
tip.setAttribute("role", "status");
var tipTitle = html("div", "ghchart__tt-title");
var tipBody = html("div", "ghchart__tt-body");
tip.appendChild(tipTitle);
tip.appendChild(tipBody);
root.appendChild(tip);
var activeIndex = -1;
function show(i) {
var pt = config.points[i];
if (!pt) return;
var reappearing = activeIndex === -1;
activeIndex = i;
dotEls.forEach(function (d, di) {
d.el.setAttribute("r", di === i ? d.baseR + 2.5 : d.baseR);
});
tipTitle.textContent = pt.label;
tipBody.textContent = "";
var line = html("div", "ghchart__tt-line");
line.textContent = fmtY(pt.y) + " · " + fmtX(pt.x) + tipSuffix;
tipBody.appendChild(line);
var anchor = dotEls[i].el.getBoundingClientRect();
var rootRect = root.getBoundingClientRect();
var left = anchor.left + anchor.width / 2 – rootRect.left;
var top = anchor.top – rootRect.top – 12;
var suppress = reappearing && !reduceMotion();
if (suppress) tip.style.transition = "opacity 0.12s ease";
tip.setAttribute("data-visible", "true");
var halfW = tip.offsetWidth / 2;
left = clamp(left, halfW + 2, root.clientWidth – halfW – 2);
if (top – tip.offsetHeight 0) {
svg.appendChild(el(“line”, {
“class”: “ghchart__phase-sep”, x1: x0, y1: M.top, x2: x0, y2: baseY
}));
}
var pl = el(“text”, {
“class”: “ghchart__phase-label”, x: (x0 + x1) / 2, y: M.top – 40,
“text-anchor”: “middle”
});
pl.textContent = ph.label;
svg.appendChild(pl);
if (ph.avg) {
var pa = el(“text”, {
“class”: “ghchart__phase-avg”, x: (x0 + x1) / 2, y: M.top + 24,
“text-anchor”: “middle”, fill: cPoint
});
pa.textContent = ph.avg;
svg.appendChild(pa);
}
});
// Horizontal gridlines + y tick labels (skip a gridline at the reference y).
var refY = config.reference ? config.reference.y : null;
(ya.ticks || []).forEach(function (tv) {
var gy = yAt(tv);
if (refY === null || Math.abs(tv – refY) > 1e-9) {
svg.appendChild(el(“line”, {
“class”: “ghchart__gridline”, x1: M.left, y1: gy, x2: VB.w – M.right, y2: gy
}));
}
var yl = el(“text”, {
“class”: “ghchart__axislabel ghchart__axislabel–y”,
x: M.left – 16, y: gy, dy: “0.32em”, “text-anchor”: “end”
});
yl.textContent = fmtY(tv);
svg.appendChild(yl);
});
// X tick labels (dated).
(xa.ticks || []).forEach(function (t) {
var gx = xAt(t.at);
var first = Math.abs(t.at – xa.min) < 1e-9, last = Math.abs(t.at – xa.max) < 1e-9;
var xl = el("text", {
"class": "ghchart__axislabel ghchart__axislabel–x",
x: gx, y: baseY + 30, "text-anchor": first ? "start" : (last ? "end" : "middle")
});
xl.textContent = t.text;
svg.appendChild(xl);
});
// Axis titles.
if (ya.title) {
var ytX = 22, ytY = M.top + innerH / 2;
var yt = el("text", {
"class": "ghchart__axistitle", x: ytX, y: ytY, "text-anchor": "middle",
transform: "rotate(-90 " + ytX + " " + ytY + ")"
});
yt.textContent = ya.title;
svg.appendChild(yt);
}
// Trend: optional ±band (linear only), then the dashed line + slope label.
var tr = config.trend;
if (tr) {
function trendY(x) {
return tr.from.y + (tr.to.y – tr.from.y) * (x – tr.from.x) / (tr.to.x – tr.from.x);
}
if (tr.band && !isLog) {
var p = tr.band;
var pts = [
[xAt(tr.from.x), yAt(trendY(tr.from.x) + p)],
[xAt(tr.to.x), yAt(trendY(tr.to.x) + p)],
[xAt(tr.to.x), yAt(trendY(tr.to.x) – p)],
[xAt(tr.from.x), yAt(trendY(tr.from.x) – p)]
].map(function (a) { return a[0] + "," + a[1]; }).join(" ");
svg.appendChild(el("polygon", {
"class": "ghchart__trendband", points: pts, fill: cTrend
}));
}
svg.appendChild(el("line", {
"class": "ghchart__trendline",
x1: xAt(tr.from.x), y1: yAt(tr.from.y),
x2: xAt(tr.to.x), y2: yAt(tr.to.y), stroke: cTrend
}));
if (tr.label) {
var lx, lyy;
if (tr.labelAt) { lx = xAt(tr.labelAt.x); lyy = yAt(tr.labelAt.y); }
else {
lx = xAt(tr.to.x) + (tr.labelDX == null ? -6 : tr.labelDX);
lyy = yAt(tr.to.y) + (tr.labelDY == null ? -12 : tr.labelDY);
}
var sl = el("text", {
"class": "ghchart__slope", x: lx, y: lyy,
"text-anchor": tr.labelAnchor || "end", fill: cTrend
});
sl.textContent = tr.label;
svg.appendChild(sl);
}
}
// Reference line (horizontal dashed) + right-aligned label.
if (config.reference) {
var rc = config.reference.color || "#24292f";
var ryy = yAt(config.reference.y);
svg.appendChild(el("line", {
"class": "ghchart__refline", x1: M.left, y1: ryy, x2: VB.w – M.right, y2: ryy,
stroke: rc
}));
if (config.reference.label) {
var rl = el("text", {
"class": "ghchart__reflabel", x: VB.w – M.right, y: ryy – 8,
"text-anchor": "end", fill: rc
});
rl.textContent = config.reference.label;
svg.appendChild(rl);
}
}
// Data squares (values reveal on hover only).
var side = 13, hSide = side / 2;
var sqEls = [], hitEls = [];
(config.points || []).forEach(function (pt, i) {
var cx = xAt(pt.x), cy = yAt(pt.y);
var sq = el("rect", {
"class": "ghchart__square", x: cx – hSide, y: cy – hSide,
width: side, height: side, rx: 2, fill: cPoint
});
svg.appendChild(sq);
sqEls.push({ el: sq, cx: cx, cy: cy });
// Hover/focus hit target — omitted when hover is disabled so the
// (illustrative) point values never surface, on screen or to a11y.
if (!config.disableHover) {
var hit = el("rect", {
"class": "ghchart__squarehit", x: cx – 13, y: cy – 13,
width: 26, height: 26, tabindex: "0", role: "img",
"aria-label": (pt.d || "") + " " + fmtY(pt.y)
});
svg.appendChild(hit);
hitEls.push(hit);
}
});
// Invalid-run markers: red diamond + X, leader line, annotation text.
(config.invalids || []).forEach(function (iv) {
var cx = xAt(iv.x), cy = yAt(iv.y);
svg.appendChild(el("rect", {
"class": "ghchart__invalid", x: cx – hSide, y: cy – hSide,
width: side, height: side, rx: 1.5, fill: "none", stroke: cInvalid,
transform: "rotate(45 " + cx + " " + cy + ")"
}));
var q = 3.4;
svg.appendChild(el("line", {
"class": "ghchart__invalid-x", x1: cx – q, y1: cy – q, x2: cx + q, y2: cy + q,
stroke: cInvalid
}));
svg.appendChild(el("line", {
"class": "ghchart__invalid-x", x1: cx – q, y1: cy + q, x2: cx + q, y2: cy – q,
stroke: cInvalid
}));
if (iv.title) {
var tx = cx + (iv.labelDX == null ? 16 : iv.labelDX);
var ty = cy + (iv.labelDY == null ? -26 : iv.labelDY);
svg.appendChild(el("line", {
"class": "ghchart__leader", x1: cx, y1: cy, x2: tx, y2: ty, stroke: cInvalid
}));
var anchor = iv.labelAnchor || "start";
var at = el("text", {
"class": "ghchart__anno-title", x: tx, y: ty – 4, "text-anchor": anchor, fill: cInvalid
});
at.textContent = iv.title;
svg.appendChild(at);
if (iv.sub) {
var asu = el("text", {
"class": "ghchart__anno-sub", x: tx, y: ty + 11, "text-anchor": anchor
});
asu.textContent = iv.sub;
svg.appendChild(asu);
}
}
});
// Bracket annotation (span with end ticks + centered label above).
(config.brackets || []).forEach(function (b) {
var x0 = xAt(b.fromX), x1 = xAt(b.toX), by = yAt(b.y);
svg.appendChild(el("line", {
"class": "ghchart__bracket", x1: x0, y1: by, x2: x1, y2: by
}));
svg.appendChild(el("line", { "class": "ghchart__bracket", x1: x0, y1: by, x2: x0, y2: by + 7 }));
svg.appendChild(el("line", { "class": "ghchart__bracket", x1: x1, y1: by, x2: x1, y2: by + 7 }));
var bl = el("text", {
"class": "ghchart__bracket-label", x: (x0 + x1) / 2, y: by – 8, "text-anchor": "middle"
});
bl.textContent = b.text;
svg.appendChild(bl);
});
// Final callout: ringed point + leader + title/sub placed inside the plot.
if (config.callout) {
var co = config.callout;
var ccx = xAt(co.x), ccy = yAt(co.y);
svg.appendChild(el("circle", {
"class": "ghchart__callout-ring", cx: ccx, cy: ccy, r: 12, stroke: cPoint
}));
var ctx = ccx + (co.labelDX == null ? -16 : co.labelDX);
var cty = ccy + (co.labelDY == null ? -30 : co.labelDY);
svg.appendChild(el("line", {
"class": "ghchart__leader ghchart__leader–callout", x1: ccx, y1: ccy, x2: ctx, y2: cty,
stroke: cPoint
}));
var can = co.labelAnchor || "end";
var ct = el("text", {
"class": "ghchart__anno-title ghchart__anno-title–callout", x: ctx, y: cty – 4, "text-anchor": can, fill: cPoint
});
ct.textContent = co.title;
svg.appendChild(ct);
if (co.sub) {
var cs = el("text", {
"class": "ghchart__anno-sub ghchart__anno-sub–callout", x: ctx, y: cty + 12, "text-anchor": can
});
cs.textContent = co.sub;
svg.appendChild(cs);
}
}
root.appendChild(svg);
if (config.caption) {
var cap = html("div", "ghchart__caption");
cap.textContent = config.caption;
root.appendChild(cap);
}
/* —- Tooltip + hover interaction (skipped when disabled) —— */
if (!config.disableHover) {
var tip = html("div", "ghchart__tooltip");
tip.setAttribute("role", "status");
var tipTitle = html("div", "ghchart__tt-title");
var tipBody = html("div", "ghchart__tt-body");
tip.appendChild(tipTitle);
tip.appendChild(tipBody);
root.appendChild(tip);
var activeIndex = -1;
function show(i) {
var pt = config.points[i];
if (!pt) return;
var reappearing = activeIndex === -1;
activeIndex = i;
sqEls.forEach(function (s, si) {
var big = si === i;
var w = big ? side + 5 : side;
s.el.setAttribute("width", w);
s.el.setAttribute("height", w);
s.el.setAttribute("x", s.cx – w / 2);
s.el.setAttribute("y", s.cy – w / 2);
});
var tipInfo = config.pointTip
? config.pointTip(pt)
: { title: pt.d || "", line: fmtY(pt.y) };
tipTitle.textContent = tipInfo.title;
tipBody.textContent = "";
var line = html("div", "ghchart__tt-line");
line.textContent = tipInfo.line;
tipBody.appendChild(line);
var anchor = sqEls[i].el.getBoundingClientRect();
var rootRect = root.getBoundingClientRect();
var left = anchor.left + anchor.width / 2 – rootRect.left;
var top = anchor.top – rootRect.top – 12;
var suppress = reappearing && !reduceMotion();
if (suppress) tip.style.transition = "opacity 0.12s ease";
tip.setAttribute("data-visible", "true");
var halfW = tip.offsetWidth / 2;
left = clamp(left, halfW + 2, root.clientWidth – halfW – 2);
if (top – tip.offsetHeight < 0) {
top = anchor.bottom – rootRect.top + 12;
tip.style.transform = "translate(-50%, 0)";
} else {
tip.style.transform = "translate(-50%, -100%)";
}
tip.style.left = left + "px";
tip.style.top = top + "px";
if (suppress) { void tip.offsetWidth; tip.style.transition = ""; }
}
function hide() {
activeIndex = -1;
tip.setAttribute("data-visible", "false");
sqEls.forEach(function (s) {
s.el.setAttribute("width", side);
s.el.setAttribute("height", side);
s.el.setAttribute("x", s.cx – hSide);
s.el.setAttribute("y", s.cy – hSide);
});
}
hitEls.forEach(function (hit, i) {
hit.addEventListener("pointerenter", function () { show(i); });
hit.addEventListener("pointermove", function () { show(i); });
hit.addEventListener("pointerleave", hide);
hit.addEventListener("focus", function () { show(i); });
hit.addEventListener("blur", hide);
});
}
};
/* ================================================================
Distribution dashboard renderer
—————————————————————-
A composition figure (no hover — every value is printed as text, so
the marks are inherently screen-reader accessible):
• a compact narrative headline + muted subtitle;
• an optional segmented "mix" bar — proportional colored segments,
each with its name inside and count/percent below;
• one or more panels of ranked horizontal bars, each row printing
its category (left), a bar, and its count/percent (right).
Colors come from config.series so the harness can tune them live:
series[0] = bar fill (single base hex);
series[1..] = segment fills, in the order of config.segments.items.
================================================================ */
renderers.distribution = function (root, config) {
root.textContent = "";
root.setAttribute("role", "figure");
if (config.title) root.setAttribute("aria-label", config.title);
var series = config.series || [];
var barColor = (series[0] && series[0].color) || "#8250df";
var total = config.total ||
(config.segments && config.segments.items
? config.segments.items.reduce(function (a, b) { return a + b.value; }, 0)
: 0);
function pctText(v) {
if (!total) return "";
return (Math.round((v / total) * 1000) / 10).toFixed(1) + "%";
}
/* Label format is config-driven so a chart can print counts, percents,
or both without a renderer change: config.valueFormat =
"both" (default) | "percent" | "count". Counts stay in the data either
way so the percentages are always derived from them. */
var valueFormat = config.valueFormat || "both";
function valueHTML(v) {
var p = pctText(v);
if (valueFormat === "percent") return p;
if (valueFormat === "count") return "” + v + ““;
return “” + v + “” + (p ? ” (” + p + “)” : “”);
}
/* —- Headline + subtitle ————————————- */
if (config.title) {
var head = html(“div”, “ghchart__headline”);
head.textContent = config.title;
root.appendChild(head);
}
if (config.subtitle) {
var sub = html(“p”, “ghchart__subtitle”);
sub.textContent = config.subtitle;
root.appendChild(sub);
}
/* —- Segmented “mix” bar ————————————– */
var seg = config.segments;
if (seg && seg.items && seg.items.length) {
var segSec = html(“div”, “ghchart__dist-section”);
if (seg.title) {
var segTitle = html(“div”, “ghchart__panel-title”);
segTitle.textContent = seg.title;
segSec.appendChild(segTitle);
}
var segTotal = seg.items.reduce(function (a, b) { return a + b.value; }, 0) || 1;
var bar = html(“div”, “ghchart__seg”);
var notes = html(“div”, “ghchart__seg-note-row”);
seg.items.forEach(function (it, i) {
var basis = (it.value / segTotal) * 100;
var col = (series[i + 1] && series[i + 1].color) || it.color || barColor;
var cell = html(“div”, “ghchart__seg-cell”);
cell.style.flex = “0 0 ” + basis + “%”;
cell.style.background = col;
cell.textContent = it.label;
bar.appendChild(cell);
var note = html(“div”, “ghchart__seg-note”);
note.style.flex = “0 0 ” + basis + “%”;
note.innerHTML = valueHTML(it.value);
notes.appendChild(note);
});
segSec.appendChild(bar);
segSec.appendChild(notes);
root.appendChild(segSec);
}
/* —- Ranked bar panels (grid: 2-up, stacking on narrow) —— */
var panels = config.panels || [];
if (panels.length) {
var cols = html(“div”, “ghchart__dist-cols”);
if (config.columns === 1 || panels.length === 1) {
cols.classList.add(“ghchart__dist-cols–stack”);
}
panels.forEach(function (panel) {
var sec = html(“div”, “ghchart__dist-section”);
if (panel.title) {
var pt = html(“div”, “ghchart__panel-title”);
pt.textContent = panel.title;
sec.appendChild(pt);
}
if (panel.subtitle) {
var descriptor = html(“p”, “ghchart__subtitle”);
descriptor.textContent = panel.subtitle;
sec.appendChild(descriptor);
}
if (panel.stats) {
var stats = html(“dl”, “ghchart__dist-stats”);
panel.stats.forEach(function (stat) {
var pair = html(“div”, “ghchart__dist-stat”);
var term = html(“dt”);
term.textContent = stat.label;
var value = html(“dd”);
value.textContent = stat.value;
value.style.color = strongOf((series[panel.series || 0] || series[0]).color);
pair.appendChild(term);
pair.appendChild(value);
stats.appendChild(pair);
});
sec.appendChild(stats);
cols.appendChild(sec);
return;
}
var list = html(“div”, “ghchart__dist-list”);
var pmax = (panel.items || []).reduce(function (m, it) {
return it.value > m ? it.value : m;
}, 0) || 1;
(panel.items || []).forEach(function (it) {
var row = html(“div”, “ghchart__dist-row”);
var cat = html(“div”, “ghchart__dist-cat”);
cat.textContent = it.label;
row.appendChild(cat);
var track = html(“div”, “ghchart__dist-track”);
var b = html(“div”, “ghchart__dist-bar”);
b.style.width = Math.max((it.value / pmax) * 100, 1.2) + “%”;
b.style.background = it.color || barColor;
track.appendChild(b);
row.appendChild(track);
var val = html(“div”, “ghchart__dist-val”);
val.innerHTML = valueHTML(it.value);
row.appendChild(val);
list.appendChild(row);
});
sec.appendChild(list);
cols.appendChild(sec);
});
root.appendChild(cols);
}
/* —- Caption / footnote ————————————– */
if (config.caption) {
var cap = html(“div”, “ghchart__caption”);
cap.innerHTML = config.caption;
root.appendChild(cap);
}
};
/* ================================================================
Timeline / staged-duration renderer
—————————————————————-
A horizontal time axis broken into labeled stage segments. Composes
only the pieces the data needs (like the trend renderer):
• eyebrow kicker + narrative headline + optional subtitle;
• an optional before/after stat card and a stage legend;
• one or more rows of segmented duration bars on a shared time
scale — a row is either sequential (segments end to end) or
parallel (a full-height “gate” segment then concurrent lanes);
• optional flow ribbons linking matching stages between the first
two rows (the sequential -> parallel “collapse”);
• optional elbow-leader callouts above narrow segments;
• optional span brackets (below a row, or right of a parallel group);
• an optional numeric axis (ticks + gridlines + title).
No hover — every label is printed, so it reads on paper and to screen
readers. Stage colors come from config.series so the harness tunes
them live: each entry is { id, name, color }.
================================================================ */
renderers.timeline = function (root, config) {
root.textContent = “”;
root.setAttribute(“role”, “img”);
if (config.title) root.setAttribute(“aria-label”, config.title);
var stages = config.series || [];
var stageById = {};
stages.forEach(function (s) { if (s && s.id) stageById[s.id] = s; });
function colorOf(seg) {
return seg.color || (stageById[seg.id] && stageById[seg.id].color) || “#8250df”;
}
function nameOf(seg) {
if (seg.label != null) return seg.label;
return seg.name || (stageById[seg.id] && stageById[seg.id].name) || “”;
}
/* —- DOM header: eyebrow + headline + subtitle ————— */
if (config.eyebrow) {
var eb = html(“div”, “ghchart__tl-eyebrow”);
eb.textContent = config.eyebrow;
root.appendChild(eb);
}
if (config.title) {
var hl = html(“div”, “ghchart__headline ghchart__tl-headline”);
hl.textContent = config.title;
root.appendChild(hl);
}
if (config.subtitle) {
var sbt = html(“p”, “ghchart__subtitle”);
sbt.textContent = config.subtitle;
root.appendChild(sbt);
}
/* —- Stat card (before -> after + delta) ——————– */
if (config.stat) {
var st = config.stat;
var card = html(“div”, “ghchart__tl-stat”);
var main = html(“div”, “ghchart__tl-stat-main”);
function statCol(k, v, muted) {
var col = html(“div”, “ghchart__tl-stat-col”);
var kk = html(“div”, “ghchart__tl-stat-k”); kk.textContent = k; col.appendChild(kk);
var vv = html(“div”, “ghchart__tl-stat-v” + (muted ? ” ghchart__tl-stat-v–muted” : “”));
vv.textContent = v; col.appendChild(vv);
return col;
}
main.appendChild(statCol(st.beforeLabel, st.beforeValue, true));
var arrow = html(“div”, “ghchart__tl-stat-arrow”); arrow.textContent = “u2192”;
main.appendChild(arrow);
main.appendChild(statCol(st.afterLabel, st.afterValue, false));
card.appendChild(main);
if (st.delta) {
var dl = html(“div”, “ghchart__tl-stat-delta”);
var dv = html(“div”, “ghchart__tl-stat-delta-v”); dv.textContent = st.delta; dl.appendChild(dv);
if (st.deltaLabel) {
var dk = html(“div”, “ghchart__tl-stat-delta-k”); dk.textContent = st.deltaLabel; dl.appendChild(dk);
}
card.appendChild(dl);
}
root.appendChild(card);
}
/* —- Legend ————————————————– */
if (config.legend && config.legend.length) {
var lg = html(“div”, “ghchart__tl-legend”);
config.legend.forEach(function (item) {
var s = stageById[item.id] || {};
var it = html(“span”, “ghchart__tl-legend-item”);
var sw = html(“span”, “ghchart__tl-swatch”);
sw.style.background = item.color || s.color || “#8250df”;
it.appendChild(sw);
var tx = html(“span”, “ghchart__tl-legend-text”);
tx.textContent = (item.name || s.name || “”) + (item.value ? ” u00b7 ” + item.value : “”);
it.appendChild(tx);
lg.appendChild(it);
});
root.appendChild(lg);
}
if (config.rule) root.appendChild(html(“div”, “ghchart__tl-rule”));
/* —- Plot geometry ——————————————- */
var rows = config.rows || [];
function rowTotal(r) {
if (r.parallel) {
var g = r.gate ? r.gate.value : 0, mx = 0;
r.parallel.forEach(function (p) { if (p.value > mx) mx = p.value; });
return g + mx;
}
var t = 0; (r.segments || []).forEach(function (s) { t += s.value; }); return t;
}
var maxTotal = 0;
rows.forEach(function (r) { var t = rowTotal(r); if (t > maxTotal) maxTotal = t; });
var axis = config.axis;
var scaleMax = config.scaleMax || (axis && axis.max) || maxTotal || 1;
var VB_W = 1000;
var hasRowLabels = rows.some(function (r) { return r.label; });
var M = { left: hasRowLabels ? 150 : 44, right: 46 };
var innerW = VB_W – M.left – M.right;
function xAt(v) { return M.left + innerW * v / scaleMax; }
function wAt(v) { return innerW * v / scaleMax; }
var SEQ_H = 56, LANE_H = 13, LANE_GAP = 6, SEG_R = 4;
function rowBandH(r) {
if (r.parallel) {
var n = r.parallel.length;
return Math.max(SEQ_H, n * LANE_H + (n – 1) * LANE_GAP);
}
return SEQ_H;
}
var flows = !!config.flows && rows.length >= 2;
var FLOW_GAP = config.flowGap != null ? config.flowGap : 250, ROW_GAP = 48;
// Reserve vertical space for above-callouts (tallest tier wins).
var anyAbove = rows.some(function (r) {
return (r.segments || []).some(function (s) { return s.labelPos === “above”; });
});
var maxTier = 0;
rows.forEach(function (r) {
(r.segments || []).forEach(function (s) {
if (s.labelPos === “above” && (s.tier || 0) > maxTier) maxTier = s.tier || 0;
});
});
var CALLOUT_TIER_H = 40;
var topPad = anyAbove ? (18 + (maxTier + 1) * CALLOUT_TIER_H) : 16;
// Assign each row a vertical band.
var cursor = topPad;
var rowLayout = rows.map(function (r, i) {
var top = cursor, h = rowBandH(r);
cursor = top + h;
if (i row1), matched by stage id (drawn under bars).
if (flows) {
var r0 = rows[0], l0 = rowLayout[0], r1 = rows[1], l1 = rowLayout[1];
var beforeSpan = {}, acc0 = 0;
(r0.segments || []).forEach(function (s) {
beforeSpan[s.id] = { x0: xAt(acc0), x1: xAt(acc0 + s.value) }; acc0 += s.value;
});
var afterSpan = {}, gv1 = r1.gate ? r1.gate.value : 0;
if (r1.gate) afterSpan[r1.gate.id] = { x0: xAt(0), x1: xAt(gv1), y: l1.top };
(r1.parallel || []).forEach(function (p, k) {
// Land each ribbon on its companion lane’s actual depth, not the row top.
afterSpan[p.id] = {
x0: xAt(gv1), x1: xAt(gv1 + p.value), y: l1.top + k * (LANE_H + LANE_GAP)
};
});
var by = l0.bottom;
Object.keys(beforeSpan).forEach(function (id) {
var b = beforeSpan[id], a = afterSpan[id];
if (!a) return;
var ay = a.y, ddy = (ay – by) * 0.5;
var d = “M ” + b.x0 + ” ” + by +
” C ” + b.x0 + ” ” + (by + ddy) + ” ” + a.x0 + ” ” + (ay – ddy) + ” ” + a.x0 + ” ” + ay +
” L ” + a.x1 + ” ” + ay +
” C ” + a.x1 + ” ” + (ay – ddy) + ” ” + b.x1 + ” ” + (by + ddy) + ” ” + b.x1 + ” ” + by + ” Z”;
var flowPath = el(“path”, {
“class”: “ghchart__tl-flow”, d: d, fill: colorOf({ id: id }), “data-stage”: id
});
svg.appendChild(flowPath);
hoverSlot(id).flowEl = flowPath;
});
}
// Rows: segmented bars.
rows.forEach(function (r, ri) {
var L = rowLayout[ri];
if (r.label) {
var rl = el(“text”, {
“class”: “ghchart__tl-rowlabel”, x: M.left – 20, y: L.top + L.h / 2 – 3, “text-anchor”: “end”
});
rl.textContent = r.label; svg.appendChild(rl);
if (r.sublabel) {
var rs = el(“text”, {
“class”: “ghchart__tl-rowsub”, x: M.left – 20, y: L.top + L.h / 2 + 18, “text-anchor”: “end”
});
rs.textContent = r.sublabel; svg.appendChild(rs);
}
}
if (r.parallel) {
var gv = r.gate ? r.gate.value : 0;
if (r.gate) {
var gateEl = el(“rect”, {
“class”: “ghchart__tl-seg”, x: xAt(0), y: L.top,
width: Math.max(0, wAt(gv)), height: L.h, rx: SEG_R, fill: colorOf(r.gate),
“data-stage”: r.gate.id
});
svg.appendChild(gateEl);
var gslot = hoverSlot(r.gate.id);
gslot.afterEl = gateEl; gslot.after = r.gate.value;
gslot.name = gslot.name || nameOf(r.gate);
}
r.parallel.forEach(function (p, k) {
var ly = L.top + k * (LANE_H + LANE_GAP);
var laneEl = el(“rect”, {
“class”: “ghchart__tl-seg”, x: xAt(gv), y: ly,
width: Math.max(1, wAt(p.value)), height: LANE_H, rx: 3, fill: colorOf(p),
“data-stage”: p.id
});
svg.appendChild(laneEl);
var pslot = hoverSlot(p.id);
pslot.afterEl = laneEl; pslot.after = p.value;
pslot.name = pslot.name || nameOf(p);
});
} else {
var acc = 0;
(r.segments || []).forEach(function (s) {
var x = xAt(acc), w = Math.max(1, wAt(s.value));
var segEl = el(“rect”, {
“class”: “ghchart__tl-seg”, x: x, y: L.top, width: w, height: L.h, rx: SEG_R,
fill: colorOf(s), “data-stage”: s.id
});
svg.appendChild(segEl);
var slot = hoverSlot(s.id);
slot.name = slot.name || nameOf(s);
if (ri === 0) { slot.beforeEl = segEl; slot.before = s.value; }
else { slot.afterEl = segEl; slot.after = s.value; }
if (s.labelPos === “inside”) {
var ilAttrs = {
“class”: “ghchart__tl-seg-label”, x: x + w / 2, y: L.top + L.h / 2,
dy: “0.34em”, “text-anchor”: “middle”
};
// Inline style (not a fill attribute) so it overrides the class’s
// default white fill — lets a bright segment take dark label text.
if (s.labelColor) ilAttrs.style = “fill:” + s.labelColor;
var il = el(“text”, ilAttrs);
il.textContent = nameOf(s); svg.appendChild(il);
}
acc += s.value;
});
}
});
// Elbow-leader callouts above narrow segments.
rows.forEach(function (r, ri) {
if (r.parallel) return;
var L = rowLayout[ri], acc = 0;
(r.segments || []).forEach(function (s) {
var x = xAt(acc), w = wAt(s.value), cx = x + w / 2;
if (s.labelPos === “above”) {
var tier = s.tier || 0;
var tierY = topPad – 14 – tier * CALLOUT_TIER_H;
// labelX pins the label to an absolute data-x (so callouts can be
// left-aligned into a stack); labelDX is a relative nudge otherwise.
var lx = s.labelX != null ? xAt(s.labelX) : cx + (s.labelDX || 0);
var anchor = s.labelAnchor || “start”;
svg.appendChild(el(“path”, {
“class”: “ghchart__tl-callout-line”,
d: “M ” + cx + ” ” + L.top + ” L ” + cx + ” ” + tierY + ” L ” + lx + ” ” + tierY, fill: “none”
}));
var pad = anchor === “start” ? 6 : (anchor === “end” ? -6 : 0);
var cl = el(“text”, {
“class”: “ghchart__tl-callout-label”, x: lx + pad, y: tierY,
dy: “0.34em”, “text-anchor”: anchor, fill: s.labelColor || colorOf(s)
});
cl.textContent = nameOf(s); svg.appendChild(cl);
}
acc += s.value;
});
});
// Below brackets (span with up-ticks + label beneath).
var belowY = (rowLayout.length ? rowLayout[rowLayout.length – 1].bottom : topPad) + 14;
belowBrackets.forEach(function (b, bi) {
var x0 = xAt(b.from), x1 = xAt(b.to), yy = belowY + bi * 46;
svg.appendChild(el(“line”, { “class”: “ghchart__bracket”, x1: x0, y1: yy, x2: x1, y2: yy }));
svg.appendChild(el(“line”, { “class”: “ghchart__bracket”, x1: x0, y1: yy, x2: x0, y2: yy – 7 }));
svg.appendChild(el(“line”, { “class”: “ghchart__bracket”, x1: x1, y1: yy, x2: x1, y2: yy – 7 }));
var bl = el(“text”, {
“class”: “ghchart__bracket-label ghchart__tl-bracket-label”, x: (x0 + x1) / 2, y: yy + 22, “text-anchor”: “middle”
});
bl.textContent = b.text; svg.appendChild(bl);
});
// Right brackets (vertical span beside a parallel group + label).
rightBrackets.forEach(function (b) {
var L = rowLayout[b.row || 0];
var bx = xAt(b.at != null ? b.at : rowTotal(rows[b.row || 0])) + 14;
var y0 = L.top, y1 = L.bottom;
svg.appendChild(el(“line”, { “class”: “ghchart__bracket”, x1: bx, y1: y0, x2: bx, y2: y1 }));
svg.appendChild(el(“line”, { “class”: “ghchart__bracket”, x1: bx, y1: y0, x2: bx – 7, y2: y0 }));
svg.appendChild(el(“line”, { “class”: “ghchart__bracket”, x1: bx, y1: y1, x2: bx – 7, y2: y1 }));
// Label as a DOM node (not SVG text) so it keeps a fixed, readable size
// instead of scaling down with the plot on small windows.
var rlabel = html(“div”, “ghchart__tl-bracket-label-dom”);
rlabel.textContent = b.text;
rlabel.style.left = ((bx + 12) / VB_W * 100) + “%”;
rlabel.style.top = (((y0 + y1) / 2) / VB_H * 100) + “%”;
plotwrap.appendChild(rlabel);
});
// Numeric axis: tick labels + title.
if (axis && axis.ticks) {
axis.ticks.forEach(function (tv, i) {
var gx = xAt(tv);
var first = i === 0, last = i === axis.ticks.length – 1;
var xl = el(“text”, {
“class”: “ghchart__axislabel ghchart__axislabel–x ghchart__tl-tick”,
x: gx, y: axisY + 26, “text-anchor”: first ? “start” : (last ? “end” : “middle”)
});
xl.textContent = axis.format ? axis.format(tv) : String(tv);
svg.appendChild(xl);
});
}
/* —- Hover interaction (before/after chart) ——————
Emphasise a stage’s whole connection (its before block, after block
and ribbon) and show a tooltip with the before/after values. Only
when there are flows to connect and hover isn’t disabled. */
if (flows && config.hover !== false) {
var unit = config.unit ? ” ” + config.unit : “”;
function fmtHover(v) { return v == null ? “u2013” : v + unit; }
var tip = html(“div”, “ghchart__tooltip”);
tip.setAttribute(“role”, “status”);
var tipTitle = html(“div”, “ghchart__tt-title”);
var tipBody = html(“div”, “ghchart__tt-body ghchart__tt-body–plain”);
tip.appendChild(tipTitle); tip.appendChild(tipBody);
root.appendChild(tip);
var focused = null;
function focusStage(id) {
focused = id;
Object.keys(stageHover).forEach(function (k) {
var s = stageHover[k], on = (k === id);
if (s.beforeEl) s.beforeEl.style.opacity = on ? “1” : “0.16”;
if (s.afterEl) s.afterEl.style.opacity = on ? “1” : “0.16”;
if (s.flowEl) s.flowEl.style.opacity = on ? “0.5” : “0.04”;
});
var st = stageHover[id];
tipTitle.textContent = st.name || id;
tipBody.textContent = “”;
var lb = html(“div”, “ghchart__tt-line”); lb.textContent = “Before ” + fmtHover(st.before);
var la = html(“div”, “ghchart__tt-line”); la.textContent = “After ” + fmtHover(st.after);
tipBody.appendChild(lb); tipBody.appendChild(la);
}
function clearStage() {
focused = null;
Object.keys(stageHover).forEach(function (k) {
var s = stageHover[k];
if (s.beforeEl) s.beforeEl.style.opacity = “”;
if (s.afterEl) s.afterEl.style.opacity = “”;
if (s.flowEl) s.flowEl.style.opacity = “”;
});
tip.setAttribute(“data-visible”, “false”);
}
function placeTip(clientX, clientY) {
var rr = root.getBoundingClientRect();
var left = clientX – rr.left, top = clientY – rr.top – 14;
tip.setAttribute(“data-visible”, “true”);
var halfW = tip.offsetWidth / 2;
left = clamp(left, halfW + 2, root.clientWidth – halfW – 2);
if (top – tip.offsetHeight existing.version) {
/* This bundle is newer. Every export embeds the COMPLETE engine, so this
bundle’s `renderers` is a self-contained, up-to-date set. Adopt it as
the shared registry (upgrading older same-named renderers in place),
while defensively preserving any renderer types only the older engine
happened to have. Then take over render(). */
for (var ek in existing.renderers) {
if (Object.prototype.hasOwnProperty.call(existing.renderers, ek) && !renderers[ek]) {
renderers[ek] = existing.renderers[ek];
}
}
existing.renderers = renderers;
existing.render = render;
existing.destroy = destroy;
existing.version = ENGINE_VERSION;
existing._renderCleanups = renderCleanups;
} else {
/* Same or older version already present: only contribute renderer types
the shared registry is missing (don’t clobber an equal/newer engine). */
for (var rk in renderers) {
if (Object.prototype.hasOwnProperty.call(renderers, rk) && !existing.renderers[rk]) {
existing.renderers[rk] = renderers[rk];
}
}
}
} else {
/* No engine yet, or a pre-versioning (old-style) engine loaded first.
Take over, preserving any renderers the old-style engine registered. */
if (existing && existing.renderers) {
for (var ok in existing.renderers) {
if (Object.prototype.hasOwnProperty.call(existing.renderers, ok) && !renderers[ok]) {
renderers[ok] = existing.renderers[ok];
}
}
}
global.GHChartEngine = { renderers: renderers, render: render, destroy: destroy, version: ENGINE_VERSION, _renderCleanups: renderCleanups };
}
})(typeof window !== “undefined” ? window : this);
;(function () {
“use strict”;
var CONFIG = {
mountId: “gh-chart-git-events-by-percentile”,
type: “line”,
title: “Git events per repository”,
subtitle: “By percentile, August 2026. The y-axis uses a logarithmic scale: each gridline is 10× the one below.”,
labels: [“P25”, “P50”, “P75”, “P99”, “P99.9”, “P99.99”, “P100”],
curve: “linear”,
series: [
{
name: “Fetches”,
color: “#3094ff”,
width: 3.25,
values: [1, 2, 8, 739, 10534, 214934, 514300000]
},
{
name: “Pushes”,
color: “#2da44f”,
width: 3.25,
values: [2, 4, 15, 802, 7502, 23758, 2970000]
},
{
name: “Clones”,
color: “#f0883e”,
width: 3.25,
values: [1, 3, 5, 283, 5619, 44691, 279300000]
},
{
name: “Requests (all)”,
color: “#8250df”,
width: 3.25,
values: [15, 25, 53, 7738, 80978, 570771, 1001000000]
}
],
yAxis: {
scale: “log”,
min: 1,
max: 1000000000,
ticks: [1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000, 1000000000],
title: “Events”
},
formatAxis: function (v) {
if (v >= 1e9) return v / 1e9 + “B”;
if (v >= 1e6) return v / 1e6 + “M”;
if (v >= 1e3) return v / 1e3 + “K”;
return String(v);
},
formatValue: function (v) {
if (v >= 1e9) return parseFloat((v / 1e9).toFixed(3)) + “B”;
if (v >= 1e6) return parseFloat((v / 1e6).toFixed(3)) + “M”;
return v.toLocaleString(“en-US”);
}
};
function boot() {
var root = document.getElementById(CONFIG.mountId);
if (!root || root.getAttribute(“data-gh-rendered”)) return;
root.setAttribute(“data-gh-rendered”, “1”);
window.GHChartEngine.render(CONFIG);
}
if (document.readyState === “loading”) {
document.addEventListener(“DOMContentLoaded”, boot);
} else {
boot();
}
})();
Repository activity climbs sharply at the far end of the distribution. The busiest repository on GitHub saw roughly a billion requests in August.
Beyond these highest-volume workloads, total Git activity on GitHub is also growing rapidly: between September 2025 and August 2026, it increased to more than 2x its previous level, from 218.2 billion events per month to 473.3 billion.
In September alone, developers and agents made 7.38 billion commits on GitHub, more than five times as many as a year earlier.
The repositories at the top of this curve show what agentic development looks like at its leading edge: large engineering teams running busy CI pipelines alongside growing fleets of agents. Supporting these teams means building Git infrastructure for sustained, concurrent reads and writes at a scale few repositories reach today. We’re investing deeply in Git infrastructure to meet the demands of agentic software development and give teams a foundation built for their most ambitious workloads.
Building for this scale means addressing several architectural challenges:
- Commit turnaround becomes a bottleneck per agent. An agent in a tight loop commits or checkpoints after nearly every action. Its speed is bounded by how fast a single push completes, so latency that a human would never notice becomes the limiting factor.
- Write throughput demand is increasing by orders of magnitude. Pushes grew 4.9x year over year, from 0.69 billion to 3.35 billion per month. Thousands of agents working on their own branches in one repository produce a sustained write rate that converges on a single point in our architecture.
- Merges contend on one reference. Trunk-based development, release trains, and merge queues funnel all that work onto a single ref that has to absorb every merge. Pull request merges on GitHub grew to nearly 4x their volume a year ago.
- Each push multiplies into thousands of reads. For example, CI and code scanning clone or fetch the same branch tip thousands of times per minute, and that fan-out has to be cheap. GitHub Actions alone ran 3.26 billion times in September, more than 4x as many as a year ago.
- Operations on a repository must continue to be fast. To keep them fast, we continually compact repository data and clean up objects that are no longer needed. Every new write adds to that work, and the cost compounds as volume climbs.
This is why fast clones only solve part of the problem. Reads are relatively easy to scale: add caches, add replicas, and serve the same bytes to more clients. Scaling reads is essential, but these workloads require more than that. Writes are way harder. Every push has to be stored durably and made visible consistently before the next agent or CI job can build on it.
Where today’s architecture meets new demands
The current architecture has served developers well for years. Every repository is stored by Spokes, which keeps a full copy on the local disks of several fileservers, five by default. Those fast local disks let Git operations read native repository data with low latency, and the extra copies provide redundancy while spreading reads across fileservers. When a push updates a reference, a three-phase commit protocol uses a quorum to ensure that CI, the web UI, and API clients see a consistent repository state. That pairing serves a billion repositories today.
However, the mechanism we use for durability is the same one we use for scale. The copies on disk are the source of truth, so adding read capacity means adding another durable replica. Every replica participates in every write, so a push is only as fast as the slowest replica in its set. The net effect: adding replicas to absorb read load makes writes slower.
For most repositories, this tradeoff works well. At the highest activity levels, it becomes a ceiling: adding read replicas adds overhead to writes, losing a replica reduces read capacity, and losing quorum stops writes entirely. To meet agent-first demands, we need to separate durability from scale without losing what teams rely on today.
Built for the busiest, better for everyone
We’re rebuilding the infrastructure while GitHub keeps running. There’s no maintenance window where the world’s code stops moving, and no version of this work where we ask people to change how they build software while we do it.
We’re building for the most demanding workloads on GitHub: an enterprise shipping under strict regulatory requirements, a team landing a change across a repo that builds an operating system, and an organization running thousands of agents against a single codebase. Engineering for that scale raises the floor for everyone. The maintainer reviewing contributions from volunteers across time zones and the student opening their first pull request get the same faster, more resilient foundation.
The new architecture also must preserve the controls teams already operate on. A maintainer needs branch protections and required reviews so an unreviewed change never reaches the default branch. A security team needs audit logs and repository visibility to investigate a suspicious access event. An on-call engineer needs dependable automation and enough observability to understand why a deployment failed.
For the platform to keep serving everyone here while it scales for the busiest workloads, these are our guiding principles:
- Build on the workflows developers already trust. Teams rely on workflows like branching, review, merge, and history to build, ship, and govern software at scale. Our new infrastructure is designed to support those same workflows at much higher volumes of activity.
- Put reliability first. We measure every decision against the reliability that developers and organizations require. Confidence in the platform is what lets an engineering organization build automation, ship on a schedule, meet compliance obligations, and understand the software it produces. This work will meaningfully improve throughput and scale, and those gains extend a foundation of trust that’s already there.
- Keep people in control of their code. If the system isn’t helping the people and organizations who use it, and isn’t under their control, it isn’t worth building. As agents take on more of the work, the people who own the code can still review, understand, and approve it.
The approach
We are building a new GitHub architecture that can scale much more effectively. Our approach centers around core distributed systems design tenets, applied to the concurrency and scale of agentic software development. Our goal is to continue the forward momentum for open-source communities and enterprises around the world who have built their projects with Git and GitHub, while preserving and adapting the features and controls around it to meet the new needs of the agentic era.
Minimize coordination
A repository that receives many pushes must accept and publish updates quickly. Coordination is valuable when it protects correctness, but too much of it limits write throughput and can turn a busy repository into a bottleneck. Our current architecture is tightly coupled in places it doesn’t need to be, which limits our ability to scale across reads and writes without tough trade-offs. We’re redesigning the system to preserve the coordination that Git semantics require and let everything else proceed independently.
- Coordinate only what needs agreement. The part of a push that truly needs agreement is the reference update itself. Storing the underlying objects, validating object connectivity, and secret scanning are much more work, but most of it can happen in parallel to other writes. That shrinks the critical path of a push to the small step that needs coordination, so the rest of the work no longer delays the acknowledgment.
- Move maintenance off the serving path. Compaction and garbage collection are among the heaviest work a repository does, and today they run on the same hosts that answer live Git requests. In the new architecture, separate workers handle maintenance directly against durable storage. A busy repository can be optimized continuously in the background without slowing pushes and fetches.
Decouple storage from compute
Today, complete repository copies on local disks serve both as durable storage and as the layer that answers Git requests. Separating the two lets us scale each one independently.
- Scale reads without adding durable copies. In the new architecture, read capacity comes from lightweight workers that cache data to serve requests. The authoritative copy of the repository lives in a durable storage layer underneath. That way, the platform can absorb large read spikes from CI fan-out, agent fleets, and large clones without adding work to every push.
- Let each layer do one job. Authoritative repository data lives in Azure Blob Storage, which already provides durability and replication at Azure scale. The compute layer is optimized for throughput at the lowest latency.
- Recover faster from failures. When storage and compute are coupled, losing a host reduces both capacity and durability, and recovery means rebuilding a full repository copy. When they’re separate, losing a compute worker is closer to a cache miss: a replacement worker can start serving requests right away and fill its cache from durable storage as traffic arrives.
- Match capacity to demand. Compute workers can be added or removed as traffic changes instead of provisioning for peak load in advance. A repository going through a burst of activity, like a release or a new agent fleet coming online, can get extra capacity for the burst. Once it passes, that capacity goes away.
Together, these tenets allow us to support higher throughput and more concurrent work without abandoning the reliability and controls that our users need.
What comes next
We’re building an architecture designed to provide the highest throughput and reliability available. Reads and writes scale independently, and the system recovers gracefully from failures. In internal benchmarks, it has delivered up to 35 times higher write throughput, with read capacity that scales on its own to meet demand.
As automated development increases the frequency and concurrency of software change, GitHub will evolve its foundations without trading away the governance and control that teams rely on. We’re already putting that foundation in place. In the next post in this series, we’ll dive deeper into our future architecture and the journey that led us there.
The post Building Git infrastructure for agent-scale development appeared first on The GitHub Blog.