Skip to content

The Edge of the Cyber World See the latest

Apps

From 2,500 requests to 24: Rebuilding star history without the stargazers

Maintainers look at star history to answer a practical question: did a launch, release, or event change a project’s trajectory? To reconstruct that curve from the GitHub API, tools had to request pages of stargazer records (each person who currently stars the repository and when they starred it), keep the timestamps, and throw the identities away.

Over time, that design created problems at both ends. Identities were available for bulk collection, so bad actors started scraping public stargazer lists for spam and phishing campaigns. For highly starred repositories, the endpoint could also make the database process many thousands of entries to return a single page.

The fix seemed clear: return counts instead of people. But returning less data didn’t actually make the query any cheaper.

Now maintainers have a direct way to get the curve: we built an endpoint that returns up to 30 weeks of daily star counts in a single request, with no identities attached. Keeping that cheap to serve meant reducing how much data the database processed, not just how much it returned, and the endpoint does that by filtering stars by time before it counts anything.

What maintainers need from star history

We first addressed the immediate risks to developers and to our database by restricting stargazer and watcher lists to repository administrators and collaborators. After the access change, callers without the required permission could receive an empty response or a 403 Forbidden response.

That change also cut off tools that had used the old response to calculate star history, often without displaying or otherwise using the usernames it returned. So we talked with maintainers and tool builders to understand what they still needed. They used star history as an approximate signal of interest: to compare activity before and after a release, or to see whether an announcement reached new developers. What they cared about was the curve.

The old API response had made identity look like part of that contract because every timestamp arrived attached to a user. In practice, tools such as Star History needed counts and dates, not usernames or other user data. Once identity was no longer part of the requirement, we could design an aggregate endpoint for the actual job: retrieving changes in star counts over time at a useful resolution.

When the API change disrupted Star History, users rallied around the project. GitHub’s Community team listened and connected us with product engineering, who dug into our use case and worked alongside us to design a purpose-built endpoint. A difficult situation became a constructive collaboration between GitHub, Star History, and the broader community—ultimately leading to a better solution.

Tianzhou Chen, star-history.com maintainer and Bytebase co-founder

Why the original endpoint was expensive

Once we knew what maintainers needed, the next step was understanding why the original design couldn’t provide it cheaply. The original GET /repos/{owner}/{repo}/stargazers endpoint was built to answer a simple question: who starred this repository? It returned a paginated list of people, and if the request specified a custom media type, each entry also included when the star was created.

The star model was built for the same job. It records the current set of people who have starred a repository, which keeps showing a star count or a list of stargazers simple. It was not designed as a historical event log: when someone removes a star, that record is removed. When tools began using the list as a time series, reconstructing a growth curve meant retrieving individual identities and processing every current star. Because each page held at most 100 people, the work grew with the repository’s popularity: building the full curve for react/react, with about 251,000 stars, took about 2,511 requests, so tools often ran into rate limits or sampled pages instead of reading them all.

Serving a page required the database to query the repository’s current stars. Due to a bug in the pagination implementation, every request also counted all of those stars to work out how many pages existed, even though the caller received only one page of up to 100 people. On a highly starred repository, that meant counting many thousands of entries on every request. In the week before we restricted access, nearly 37,000 of these count queries ran slowly enough to be logged, averaging nearly 3 seconds each, and database safeguards killed more than 1,000 of them, which contributed to request timeouts.

The chart below shows what all that counting cost. After we restricted access, the database served about 60% fewer stargazer requests, and the total time it spent on the count query fell by about 80%.

.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__axistick {
stroke: var(–ghc-axis);
stroke-width: 1.5;
}

.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; }
.ghchart__barhit:focus-visible {
fill: rgba(9, 105, 218, 0.06);
stroke: var(–ghc-axis);
stroke-width: 2;
}
.ghchart__stacked-annotation-line {
stroke: #656d76;
stroke-width: 1.5;
stroke-dasharray: 6 5;
pointer-events: none;
}
.ghchart__stacked-annotation-label {
font-size: 18px;
font-weight: 600;
fill: var(–ghc-text);
pointer-events: none;
}

/* 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; 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 <= 1 ? M.left + innerW / 2 : M.left + PAD_X + stepX * i; }
var logLo = isLog ? Math.log(yMin) / Math.LN10 : 0;
var logHi = isLog ? Math.log(yMax) / Math.LN10 : 0;
function yAt(v) {
var t = isLog
? (Math.log(Math.max(v, yMin)) / Math.LN10 – logLo) / (logHi – logLo)
: (v – yMin) / (yMax – yMin);
return M.top + innerH * (1 – t);
}
var baseY = yAt(yMin);

/* —- Title + divider (top) ———————————- */
var title = html("span", "ghchart__title");
title.textContent = config.title;
root.appendChild(title);
root.appendChild(html("div", "ghchart__rule"));
if (config.subtitle) {
var lineSub = html("p", "ghchart__subtitle");
lineSub.textContent = config.subtitle;
root.appendChild(lineSub);
}

/* —- Header + legend —————————————- */
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");
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",
viewBox: "0 0 " + VB.w + " " + VB.h,
preserveAspectRatio: "xMidYMid meet",
role: "img",
"aria-label": config.title + " line chart"
});

// Gradient defs (one per area series).
var defs = el("defs");
config.series.forEach(function (s, si) {
if (!s.area) return;
var g = el("linearGradient", {
id: uid + "-grad-" + si, x1: "0", y1: "0", x2: "0", y2: "1"
});
g.appendChild(el("stop", { offset: "0", "stop-color": s.color, "stop-opacity": "0.28" }));
g.appendChild(el("stop", { offset: "0.55", "stop-color": s.color, "stop-opacity": "0.08" }));
g.appendChild(el("stop", { offset: "1", "stop-color": s.color, "stop-opacity": "0" }));
defs.appendChild(g);
});
// Diagonal-hatch pattern for an optional "partial / projected" band.
if (config.projection) {
var pat = el("pattern", {
id: uid + "-hatch", width: 7, height: 7,
patternUnits: "userSpaceOnUse", patternTransform: "rotate(45)"
});
pat.appendChild(el("line", {
x1: 0, y1: 0, x2: 0, y2: 7,
stroke: config.projection.color || "#8c959f",
"stroke-width": 1.1, "stroke-opacity": 0.5
}));
defs.appendChild(pat);
}
svg.appendChild(defs);

// Gridlines + y-axis labels.
yTicks.forEach(function (v) {
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 – Y_LABEL_GAP, y: gy, dy: "0.32em", "text-anchor": "end"
});
yl.textContent = fmtAxis(v);
svg.appendChild(yl);
});
if (config.yAxis.title) {
var lytX = 22, lytY = M.top + innerH / 2;
var lyt = el("text", {
"class": "ghchart__axistitle", x: lytX, y: lytY, "text-anchor": "middle",
transform: "rotate(-90 " + lytX + " " + lytY + ")"
});
lyt.textContent = config.yAxis.title;
svg.appendChild(lyt);
}

// x-axis labels — either one per data point (default) or a sparse set
// of ticks decoupled from the data (config.xTicks: [{at,text}, …]),
// which suits a dense daily series that only needs month markers.
var xTicks = config.xTicks ||
config.labels.map(function (t, i) { return { at: i, text: t }; });
xTicks.forEach(function (tk) {
if (config.showXTickMarks) {
svg.appendChild(el("line", {
"class": "ghchart__axistick",
x1: xAt(tk.at), y1: baseY,
x2: xAt(tk.at), y2: baseY + 10
}));
}
var xl = el("text", {
"class": "ghchart__axislabel ghchart__axislabel–x",
x: xAt(tk.at), y: VB.h – M.bottom + 36,
"text-anchor": config.showXTickMarks ? "middle" :
(tk.at === 0 ? "start" : (tk.at === n – 1 ? "end" : "middle"))
});
xl.textContent = tk.text;
svg.appendChild(xl);
});

// Optional hatched "partial / projected" band over the trailing region.
if (config.projection) {
var px = xAt(config.projection.fromIndex);
svg.appendChild(el("rect", {
"class": "ghchart__projection",
x: px, y: M.top, width: (VB.w – M.right) – px, height: baseY – M.top,
fill: "url(#" + uid + "-hatch)"
}));
}

// Optional dashed threshold line + floating label (e.g. a 50% target).
if (config.threshold) {
var thy = yAt(config.threshold.value);
svg.appendChild(el("line", {
"class": "ghchart__threshold",
x1: M.left, y1: thy, x2: VB.w – M.right, y2: thy,
stroke: config.threshold.color
}));
if (config.threshold.label) {
var thl = el("text", {
"class": "ghchart__threshold-label",
x: M.left + PAD_X, y: thy – 10, "text-anchor": "start",
fill: config.threshold.color
});
thl.textContent = config.threshold.label;
svg.appendChild(thl);
}
}

// Optional dated event markers (e.g. a launch or access change).
(config.annotations || []).forEach(function (annotation) {
if (!Number.isInteger(annotation.at) || annotation.at = n) return;
var ax = xAt(annotation.at);
var color = annotation.color || “#656d76”;
svg.appendChild(el(“line”, {
“class”: “ghchart__threshold”,
x1: ax, y1: M.top, x2: ax, y2: baseY,
stroke: color
}));
if (annotation.label) {
var anchorEnd = ax > VB.w – M.right – 220;
var al = el(“text”, {
“class”: “ghchart__threshold-label”,
x: ax + (anchorEnd ? -12 : 12),
y: M.top + 24,
“text-anchor”: anchorEnd ? “end” : “start”,
fill: color
});
al.textContent = annotation.label;
svg.appendChild(al);
}
});

// Precompute point coords per series.
var pts = config.series.map(function (s) {
return s.values.map(function (val, i) { return { x: xAt(i), y: yAt(val) }; });
});

// Smooth (Catmull-Rom -> 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 < nb; j++) {
var cx = xCenter(j);
var topY = yAt(values[j]);
var barH = baseY – topY;
var x = cx – barW / 2;
var r = Math.max(0, Math.min(barRadius, barW / 2, barH));
var d = "M " + x + " " + baseY +
" L " + x + " " + (topY + r) +
" Q " + x + " " + topY + " " + (x + r) + " " + topY +
" L " + (x + barW – r) + " " + topY +
" Q " + (x + barW) + " " + topY + " " + (x + barW) + " " + (topY + r) +
" L " + (x + barW) + " " + baseY + " Z";
var bar = el("path", { "class": "ghchart__bar", d: d, fill: barColorFor(panel, j) });
svg.appendChild(bar);
var rec = { el: bar, pi: pi, bi: j };
panelBars.push(rec);
allBars.push(rec);

var col = el("rect", {
"class": "ghchart__barhit",
x: M.left + slot * j, y: M.top – 6, width: slot, height: baseY – M.top + 6
});
(function (idx) {
col.addEventListener("pointermove", function () { showTip(pi, idx, panel, panelBars); });
col.addEventListener("pointerdown", function () { showTip(pi, idx, panel, panelBars); });
col.addEventListener("pointerleave", hideTip);
})(j);
svg.appendChild(col);
}

/* —- Annotation layer (drawn on top; pointer-events: none) —-
· always-on value labels above chosen bars (panel.valueLabels)
· anchor lines: a dotted horizontal reference at one bar's top
extended to another, with a floating pill (panel.annotations) */
var vlabels = panel.valueLabels || null;
if (vlabels) {
for (var vk in vlabels) {
if (!Object.prototype.hasOwnProperty.call(vlabels, vk)) continue;
var vi = +vk;
var vt = el("text", {
"class": "ghchart__barvalue",
x: xCenter(vi), y: yAt(values[vi]) – 12, "text-anchor": "middle"
});
vt.textContent = vlabels[vk];
svg.appendChild(vt);
}
}

(panel.annotations || []).forEach(function (an) {
if (an.type !== "anchor") return;
var yLine = yAt(values[an.fromIndex]);
svg.appendChild(el("line", {
"class": "ghchart__anno-line",
x1: xCenter(an.fromIndex) – barW / 2, y1: yLine,
x2: xCenter(an.toIndex) + barW / 2, y2: yLine
}));

var chipIdx = an.chipAtIndex != null ? an.chipAtIndex : an.toIndex;
var cx = xCenter(chipIdx);
var cy = (yAt(values[chipIdx]) + yLine) / 2;
var label = an.label || "";
var fs = 19;
var chipW = label.length * fs * 0.55 + 24;
var chipH = fs + 16;
svg.appendChild(el("rect", {
"class": "ghchart__anno-chip",
x: cx – chipW / 2, y: cy – chipH / 2, width: chipW, height: chipH, rx: chipH / 2
}));
var ct = el("text", {
"class": "ghchart__anno-chip-text",
x: cx, y: cy, dy: "0.34em", "text-anchor": "middle", "font-size": fs
});
ct.textContent = label;
svg.appendChild(ct);
});

wrap.appendChild(svg);
root.appendChild(wrap);
});

/* —- Caption (optional rich copy) ————————— */
if (config.caption) {
var cap = html("div", "ghchart__caption");
cap.innerHTML = config.caption;
root.appendChild(cap);
}
};

/* ================================================================
Stacked time-series bars
—————————————————————-
One bar per date, with each config.series value stacked in order.
Dated event annotations may fall between periods; set `after: true`
when an event on a week-ending date marks the boundary after its bar.
================================================================ */
renderers["stacked-bar"] = function (root, config) {
var labels = config.labels;
var dates = config.dates;
var series = config.series;
if (!Array.isArray(labels) || !labels.length ||
!Array.isArray(dates) || dates.length !== labels.length ||
!Array.isArray(series) || !series.length ||
series.some(function (s) {
return !s || typeof s.name !== "string" ||
typeof s.color !== "string" ||
!/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(s.color) ||
!Array.isArray(s.values) || s.values.length !== labels.length ||
s.values.some(function (v) { return !Number.isFinite(v) || v 0 &&
Date.parse(date + “T00:00:00Z”) <= Date.parse(dates[index – 1] + "T00:00:00Z");
}) ||
!Array.isArray(config.annotations || []) ||
(config.annotations || []).some(function (annotation) {
return !annotation || typeof annotation.at !== "string" ||
(annotation.label != null && typeof annotation.label !== "string") ||
(annotation.labelY != null && !Number.isFinite(annotation.labelY));
})) {
throw new Error("Stacked bar charts require matching labels, ISO dates, and non-negative finite values for each colored series.");
}

root.textContent = "";
root.setAttribute("role", "figure");
root.setAttribute("aria-label", config.title);

var title = html("div", "ghchart__headline");
title.textContent = config.title;
root.appendChild(title);

var legend = html("div", "ghchart__legend");
series.forEach(function (s) {
var item = html("span", "ghchart__legend-item");
var swatch = html("span", "ghchart__swatch");
swatch.style.backgroundColor = s.color;
if (config.legendSize) item.style.fontSize = config.legendSize + "px";
item.appendChild(swatch);
item.appendChild(document.createTextNode(s.name));
legend.appendChild(item);
});
root.appendChild(legend);

var tip = html("div", "ghchart__tooltip");
tip.setAttribute("role", "status");
tip.setAttribute("data-visible", "false");
var tipTitle = html("div", "ghchart__tt-title");
var tipBody = html("div", "ghchart__tt-body");
tip.appendChild(tipTitle);
tip.appendChild(tipBody);
root.appendChild(tip);

var hasXAxisTitle = !!config.xAxisTitle;
var bottomMargin = hasXAxisTitle ? 152 : 90;
var VB = { w: 1000, h: config.height || (hasXAxisTitle ? 560 : 498) };
var M = { top: 28, right: 24, bottom: bottomMargin, left: 155 };
var innerW = VB.w – M.left – M.right;
var innerH = VB.h – M.top – M.bottom;
var count = labels.length;
var axis = config.yAxis || {};
var yMin = axis.min != null ? axis.min : 0;
var yMax = axis.max;
var step = axis.step;
if (!Number.isFinite(yMin) || !Number.isFinite(yMax) || yMax <= yMin ||
!Number.isFinite(step) || step yMax || total < yMin; })) {
throw new Error("Stacked bar totals must fit within the configured yAxis range.");
}
function yAt(value) {
return M.top + innerH * (1 – (value – yMin) / (yMax – yMin));
}
function xCenter(index) { return M.left + innerW * (index + 0.5) / count; }
var slot = innerW / count;
var barW = Math.min(slot * 0.72, config.maxBarVB || 150);
var baseY = yAt(yMin);
var svg = el("svg", {
"class": "ghchart__plot ghchart__plot–bar",
viewBox: "0 0 " + VB.w + " " + VB.h,
preserveAspectRatio: "xMidYMid meet",
role: "img",
"aria-label": config.title + ". " + (axis.title || "") + ". Series: " +
series.map(function (s) { return s.name; }).join(", ") + ". " +
labels[0] + " through " + labels[count – 1]
});
var formatAxis = config.formatAxis || function (v) { return String(v); };

for (var tick = yMin; tick <= yMax + 1e-9; tick += step) {
var gy = yAt(tick);
svg.appendChild(el("line", {
"class": "ghchart__gridline", x1: M.left, y1: gy, x2: VB.w – M.right, y2: gy
}));
var yLabel = el("text", {
"class": "ghchart__axislabel ghchart__axislabel–y",
x: M.left – 16, y: gy, dy: "0.32em", "text-anchor": "end"
});
yLabel.textContent = formatAxis(tick);
svg.appendChild(yLabel);
}
svg.appendChild(el("line", {
"class": "ghchart__axisline",
x1: M.left, y1: baseY, x2: VB.w – M.right, y2: baseY
}));

var yTitleY = M.top + innerH / 2;
var yTitle = el("text", {
"class": "ghchart__axislabel ghchart__axislabel–y",
x: 26, y: yTitleY,
transform: "rotate(-90 26 " + yTitleY + ")",
"text-anchor": "middle"
});
yTitle.textContent = axis.title || "";
svg.appendChild(yTitle);

for (var xi = 0; xi < count; xi++) {
var labelX = xCenter(xi) + 7;
var labelY = baseY + 16;
var xLabel = el("text", {
"class": "ghchart__axislabel ghchart__axislabel–x",
x: labelX, y: labelY, "text-anchor": "end",
transform: "rotate(-45 " + labelX + " " + labelY + ")"
});
if (config.xLabelSize) xLabel.setAttribute("style", "font-size:" + config.xLabelSize + "px");
xLabel.textContent = labels[xi];
svg.appendChild(xLabel);
}
var xTitle = el("text", {
"class": "ghchart__axislabel ghchart__axislabel–x",
x: M.left + innerW / 2, y: VB.h – 8, "text-anchor": "middle"
});
xTitle.textContent = config.xAxisTitle || "";
svg.appendChild(xTitle);

var barsByIndex = labels.map(function () { return []; });
for (var bi = 0; bi < count; bi++) {
var cumulative = yMin;
for (var si = 0; si < series.length; si++) {
var value = series[si].values[bi];
var top = yAt(cumulative + value);
var bottom = yAt(cumulative);
var segment = el("rect", {
"class": "ghchart__bar",
x: xCenter(bi) – barW / 2, y: top, width: barW,
height: Math.max(0, bottom – top), fill: series[si].color
});
svg.appendChild(segment);
barsByIndex[bi].push(segment);
cumulative += value;
}
}

function annotationX(annotation) {
var target = Date.parse(annotation.at + "T00:00:00Z");
var parsed = dates.map(function (date) { return Date.parse(date + "T00:00:00Z"); });
if (!Number.isFinite(target) || target parsed[parsed.length – 1]) {
throw new Error(“Stacked bar annotation dates must fall within the plotted date range.”);
}
for (var di = 0; di < parsed.length; di++) {
if (target === parsed[di]) {
return xCenter(di) + (annotation.after && di < count – 1 ? slot / 2 : 0);
}
if (di parsed[di] && target VB.w – M.right ? “end” : “start”;
var text = el(“text”, {
“class”: “ghchart__stacked-annotation-label”,
x: anchor === “end” ? ax – 8 : ax + 8,
y: annotation.labelY != null
? clamp(yAt(annotation.labelY), M.top + 20, baseY – 12)
: M.top + 28,
“text-anchor”: anchor,
fill: annotation.color || “#1f2328”
});
text.textContent = annotation.label;
svg.appendChild(text);
}
});

var activeIndex = null;
var reduceMotion = window.matchMedia &&
window.matchMedia(“(prefers-reduced-motion: reduce)”).matches;
for (var hi = 0; hi < count; hi++) {
var hit = el("rect", {
"class": "ghchart__barhit",
x: M.left + slot * hi, y: M.top, width: slot, height: baseY – M.top,
tabindex: "0", role: "button",
"aria-label": labels[hi] + ": " + totals[hi].toLocaleString() + " total"
});
(function (index, hitTarget) {
function showTip() {
var reappearing = activeIndex === null;
activeIndex = index;
barsByIndex.forEach(function (barSegments, barIndex) {
barSegments.forEach(function (segment) {
segment.style.opacity = barIndex === index ? "1" : "0.42";
});
});
tipTitle.textContent = labels[index] + ", " + dates[index].slice(0, 4);
tipBody.textContent = "";
series.forEach(function (s) {
var row = html("div", "ghchart__tt-line");
row.textContent = s.name + ": " + s.values[index].toLocaleString();
tipBody.appendChild(row);
});
var totalRow = html("div", "ghchart__tt-line");
totalRow.textContent = "Total: " + totals[index].toLocaleString();
tipBody.appendChild(totalRow);
var anchor = hitTarget.getBoundingClientRect();
var rootRect = root.getBoundingClientRect();
var left = anchor.left + anchor.width / 2 – rootRect.left;
var top = anchor.top – rootRect.top – 12;
if (reappearing && !reduceMotion) 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 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-total-query-duration”,
type: “line”,
title: “Total query duration”,
hideLegend: true,
hideDots: true,
labels: [“Jun 22, 2026”, “Jun 23, 2026”, “Jun 24, 2026”, “Jun 25, 2026”, “Jun 26, 2026”, “Jun 27, 2026”, “Jun 28, 2026”, “Jun 29, 2026”, “Jun 30, 2026”, “Jul 1, 2026”, “Jul 2, 2026”, “Jul 3, 2026”, “Jul 4, 2026”, “Jul 5, 2026”, “Jul 6, 2026”, “Jul 7, 2026”, “Jul 8, 2026”, “Jul 9, 2026”, “Jul 10, 2026”, “Jul 11, 2026”, “Jul 12, 2026”, “Jul 13, 2026”, “Jul 14, 2026”, “Jul 15, 2026”, “Jul 16, 2026”, “Jul 17, 2026”, “Jul 18, 2026”, “Jul 19, 2026”, “Jul 20, 2026”, “Jul 21, 2026”, “Jul 22, 2026”, “Jul 23, 2026”, “Jul 24, 2026”, “Jul 25, 2026”, “Jul 26, 2026”, “Jul 27, 2026”, “Jul 28, 2026”, “Jul 29, 2026”, “Jul 30, 2026”, “Jul 31, 2026”, “Aug 1, 2026”, “Aug 2, 2026”, “Aug 3, 2026”, “Aug 4, 2026”, “Aug 5, 2026”, “Aug 6, 2026”, “Aug 7, 2026”, “Aug 8, 2026”, “Aug 9, 2026”, “Aug 10, 2026”, “Aug 11, 2026”, “Aug 12, 2026”, “Aug 13, 2026”, “Aug 14, 2026”, “Aug 15, 2026”, “Aug 16, 2026”, “Aug 17, 2026”, “Aug 18, 2026”, “Aug 19, 2026”, “Aug 20, 2026”, “Aug 21, 2026”],
showXTickMarks: true,
xTicks: [
{
at: 0,
text: “Jun 22”
},
{
at: 10,
text: “Jul 2”
},
{
at: 20,
text: “Jul 12”
},
{
at: 30,
text: “Jul 22”
},
{
at: 40,
text: “Aug 1”
},
{
at: 50,
text: “Aug 11”
},
{
at: 60,
text: “Aug 21”
}
],
annotations: [
{
at: 30,
label: “Access restricted · July 22”,
color: “#656d76”
}
],
series: [
{
name: “Total query time”,
color: “#2da44f”,
area: true,
width: 3,
values: [99.1, 124.2, 123.3, 117.2, 65.9, 74, 78.7, 109.3, 91.1, 90.3, 95.7, 99.2, 89.4, 83.8, 105.9, 109.1, 108.6, 105.5, 127.4, 112, 97.7, 84.6, 68.4, 66.6, 73.5, 42, 51.5, 57.9, 61.8, 63.2, 74.9, 12.3, 6.2, 7.2, 9.1, 12.7, 8.5, 6.7, 11.1, 13.7, 16.1, 18.3, 23.5, 25.8, 26.8, 18.4, 8, 10.9, 12.2, 14.1, 15.6, 16.2, 15, 16.6, 17, 16.8, 24.6, 34, 44.9, 41.1, 29.8]
}
],
yAxis: {
min: 0,
max: 150,
step: 25,
title: “Total query time (hours)”
},
formatAxis: function (v) { return String(v); },
formatValue: function (v) { return v.toFixed(1) + ” hours”; },
caption: “Daily query execution time summed across database servers · Jun 22 – Aug 21, 2026 (UTC).”
};
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();
}
})();

Approaches we considered

Removing identities handled the privacy requirement. The harder part was keeping each request from scanning the repository’s entire current star set.

Keeping events without identities

Our first spike kept one record per star and returned the starred_at value without the stargazer’s identity for callers who lacked collaborator access. At first, this looked like the quickest path forward, because it directly served the request we had heard from tool builders. It also closed the identity-harvesting path with a relatively small change to the existing serialization and authorization flow.

This approach addressed identity exposure and would have allowed existing tools to keep working. But API consumers would still have to paginate through and process potentially many thousands of individual records to build an aggregate history. It also tied the new use case to an endpoint and pagination model designed for listing people rather than retrieving trends.

Aggregating, then paginating

Aggregation seemed like the obvious next step. Instead of returning one row per star, the query could group stars into daily or weekly buckets, sort those buckets, and paginate the much smaller result.

The problem was where pagination happened. In a straightforward SQL query, the database first finds all stars for the repository, groups them into buckets, and sorts those buckets. Only then does it apply LIMIT, which controls how many buckets are returned, and OFFSET, which determines where the page begins:

all matching stars -> bucket every row by day or week -> sort every bucket -> OFFSET/LIMIT -> response

For a popular repository, an anonymous request could still scan and aggregate many thousands of index entries to return a handful of buckets. Returning fewer buckets would shrink the JSON response, but it would not reduce the expensive grouping and sorting the database had already performed. For react/react, reading the full history that way would have meant examining roughly 6 million rows, because every one of the 24 requests would have reprocessed all of its stars. To reduce the query cost, we had to narrow the rows before aggregation began.

Filtering by time before grouping

The design we shipped paginates star history by calendar week. For each request, it translates page and per_page into an indexed created_at range, filters the stars to those weeks, and then groups the matching records into daily counts. A response includes up to 30 weeks, and increasing page moves backward through the repository’s history.

indexed date range -> group rows in that range -> daily buckets -> response

With the date range applied first, each request processes the weeks being returned instead of every current star in the repository. For tools, that means one request now covers up to 30 weeks of history, no matter how many stars the repository has. For react/react, that’s about 24 requests instead of about 2,511, and the database examines each star about once (roughly 251,000 rows) across the full history. This fits the primary use case because maintainers usually inspect growth across weeks or around a known release, launch, or event. Week-based ranges also align with other repository activity APIs: the response follows the same shape as the weekly commit activity endpoint, GET /repos/{owner}/{repo}/stats/commit_activity.

The time-range design limits each request to a defined period, but it does not limit the number of stars within that range. A week with an unusually large number of stars can therefore still produce a slow or expensive query. The normal request path no longer queries every current star, but exceptionally dense weeks remain a scaling limitation.

Comparing the performance of the old and new API paths also surfaced other places where GitHub was counting stars on demand. We found the same pattern on the repository stargazers page on GitHub.com and updated it to use the repository’s cached star count. Slow queries from that page, which had numbered in the hundreds each week, dropped to zero. We recently shipped a similar fix to other stargazer API endpoints.

The chart below shows slow and killed stargazer count queries each week, by where they came from.

.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__axistick {
stroke: var(–ghc-axis);
stroke-width: 1.5;
}

.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; }
.ghchart__barhit:focus-visible {
fill: rgba(9, 105, 218, 0.06);
stroke: var(–ghc-axis);
stroke-width: 2;
}
.ghchart__stacked-annotation-line {
stroke: #656d76;
stroke-width: 1.5;
stroke-dasharray: 6 5;
pointer-events: none;
}
.ghchart__stacked-annotation-label {
font-size: 18px;
font-weight: 600;
fill: var(–ghc-text);
pointer-events: none;
}

/* 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; 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 <= 1 ? M.left + innerW / 2 : M.left + PAD_X + stepX * i; }
var logLo = isLog ? Math.log(yMin) / Math.LN10 : 0;
var logHi = isLog ? Math.log(yMax) / Math.LN10 : 0;
function yAt(v) {
var t = isLog
? (Math.log(Math.max(v, yMin)) / Math.LN10 – logLo) / (logHi – logLo)
: (v – yMin) / (yMax – yMin);
return M.top + innerH * (1 – t);
}
var baseY = yAt(yMin);

/* —- Title + divider (top) ———————————- */
var title = html("span", "ghchart__title");
title.textContent = config.title;
root.appendChild(title);
root.appendChild(html("div", "ghchart__rule"));
if (config.subtitle) {
var lineSub = html("p", "ghchart__subtitle");
lineSub.textContent = config.subtitle;
root.appendChild(lineSub);
}

/* —- Header + legend —————————————- */
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");
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",
viewBox: "0 0 " + VB.w + " " + VB.h,
preserveAspectRatio: "xMidYMid meet",
role: "img",
"aria-label": config.title + " line chart"
});

// Gradient defs (one per area series).
var defs = el("defs");
config.series.forEach(function (s, si) {
if (!s.area) return;
var g = el("linearGradient", {
id: uid + "-grad-" + si, x1: "0", y1: "0", x2: "0", y2: "1"
});
g.appendChild(el("stop", { offset: "0", "stop-color": s.color, "stop-opacity": "0.28" }));
g.appendChild(el("stop", { offset: "0.55", "stop-color": s.color, "stop-opacity": "0.08" }));
g.appendChild(el("stop", { offset: "1", "stop-color": s.color, "stop-opacity": "0" }));
defs.appendChild(g);
});
// Diagonal-hatch pattern for an optional "partial / projected" band.
if (config.projection) {
var pat = el("pattern", {
id: uid + "-hatch", width: 7, height: 7,
patternUnits: "userSpaceOnUse", patternTransform: "rotate(45)"
});
pat.appendChild(el("line", {
x1: 0, y1: 0, x2: 0, y2: 7,
stroke: config.projection.color || "#8c959f",
"stroke-width": 1.1, "stroke-opacity": 0.5
}));
defs.appendChild(pat);
}
svg.appendChild(defs);

// Gridlines + y-axis labels.
yTicks.forEach(function (v) {
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 – Y_LABEL_GAP, y: gy, dy: "0.32em", "text-anchor": "end"
});
yl.textContent = fmtAxis(v);
svg.appendChild(yl);
});
if (config.yAxis.title) {
var lytX = 22, lytY = M.top + innerH / 2;
var lyt = el("text", {
"class": "ghchart__axistitle", x: lytX, y: lytY, "text-anchor": "middle",
transform: "rotate(-90 " + lytX + " " + lytY + ")"
});
lyt.textContent = config.yAxis.title;
svg.appendChild(lyt);
}

// x-axis labels — either one per data point (default) or a sparse set
// of ticks decoupled from the data (config.xTicks: [{at,text}, …]),
// which suits a dense daily series that only needs month markers.
var xTicks = config.xTicks ||
config.labels.map(function (t, i) { return { at: i, text: t }; });
xTicks.forEach(function (tk) {
if (config.showXTickMarks) {
svg.appendChild(el("line", {
"class": "ghchart__axistick",
x1: xAt(tk.at), y1: baseY,
x2: xAt(tk.at), y2: baseY + 10
}));
}
var xl = el("text", {
"class": "ghchart__axislabel ghchart__axislabel–x",
x: xAt(tk.at), y: VB.h – M.bottom + 36,
"text-anchor": config.showXTickMarks ? "middle" :
(tk.at === 0 ? "start" : (tk.at === n – 1 ? "end" : "middle"))
});
xl.textContent = tk.text;
svg.appendChild(xl);
});

// Optional hatched "partial / projected" band over the trailing region.
if (config.projection) {
var px = xAt(config.projection.fromIndex);
svg.appendChild(el("rect", {
"class": "ghchart__projection",
x: px, y: M.top, width: (VB.w – M.right) – px, height: baseY – M.top,
fill: "url(#" + uid + "-hatch)"
}));
}

// Optional dashed threshold line + floating label (e.g. a 50% target).
if (config.threshold) {
var thy = yAt(config.threshold.value);
svg.appendChild(el("line", {
"class": "ghchart__threshold",
x1: M.left, y1: thy, x2: VB.w – M.right, y2: thy,
stroke: config.threshold.color
}));
if (config.threshold.label) {
var thl = el("text", {
"class": "ghchart__threshold-label",
x: M.left + PAD_X, y: thy – 10, "text-anchor": "start",
fill: config.threshold.color
});
thl.textContent = config.threshold.label;
svg.appendChild(thl);
}
}

// Optional dated event markers (e.g. a launch or access change).
(config.annotations || []).forEach(function (annotation) {
if (!Number.isInteger(annotation.at) || annotation.at = n) return;
var ax = xAt(annotation.at);
var color = annotation.color || “#656d76”;
svg.appendChild(el(“line”, {
“class”: “ghchart__threshold”,
x1: ax, y1: M.top, x2: ax, y2: baseY,
stroke: color
}));
if (annotation.label) {
var anchorEnd = ax > VB.w – M.right – 220;
var al = el(“text”, {
“class”: “ghchart__threshold-label”,
x: ax + (anchorEnd ? -12 : 12),
y: M.top + 24,
“text-anchor”: anchorEnd ? “end” : “start”,
fill: color
});
al.textContent = annotation.label;
svg.appendChild(al);
}
});

// Precompute point coords per series.
var pts = config.series.map(function (s) {
return s.values.map(function (val, i) { return { x: xAt(i), y: yAt(val) }; });
});

// Smooth (Catmull-Rom -> 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 < nb; j++) {
var cx = xCenter(j);
var topY = yAt(values[j]);
var barH = baseY – topY;
var x = cx – barW / 2;
var r = Math.max(0, Math.min(barRadius, barW / 2, barH));
var d = "M " + x + " " + baseY +
" L " + x + " " + (topY + r) +
" Q " + x + " " + topY + " " + (x + r) + " " + topY +
" L " + (x + barW – r) + " " + topY +
" Q " + (x + barW) + " " + topY + " " + (x + barW) + " " + (topY + r) +
" L " + (x + barW) + " " + baseY + " Z";
var bar = el("path", { "class": "ghchart__bar", d: d, fill: barColorFor(panel, j) });
svg.appendChild(bar);
var rec = { el: bar, pi: pi, bi: j };
panelBars.push(rec);
allBars.push(rec);

var col = el("rect", {
"class": "ghchart__barhit",
x: M.left + slot * j, y: M.top – 6, width: slot, height: baseY – M.top + 6
});
(function (idx) {
col.addEventListener("pointermove", function () { showTip(pi, idx, panel, panelBars); });
col.addEventListener("pointerdown", function () { showTip(pi, idx, panel, panelBars); });
col.addEventListener("pointerleave", hideTip);
})(j);
svg.appendChild(col);
}

/* —- Annotation layer (drawn on top; pointer-events: none) —-
· always-on value labels above chosen bars (panel.valueLabels)
· anchor lines: a dotted horizontal reference at one bar's top
extended to another, with a floating pill (panel.annotations) */
var vlabels = panel.valueLabels || null;
if (vlabels) {
for (var vk in vlabels) {
if (!Object.prototype.hasOwnProperty.call(vlabels, vk)) continue;
var vi = +vk;
var vt = el("text", {
"class": "ghchart__barvalue",
x: xCenter(vi), y: yAt(values[vi]) – 12, "text-anchor": "middle"
});
vt.textContent = vlabels[vk];
svg.appendChild(vt);
}
}

(panel.annotations || []).forEach(function (an) {
if (an.type !== "anchor") return;
var yLine = yAt(values[an.fromIndex]);
svg.appendChild(el("line", {
"class": "ghchart__anno-line",
x1: xCenter(an.fromIndex) – barW / 2, y1: yLine,
x2: xCenter(an.toIndex) + barW / 2, y2: yLine
}));

var chipIdx = an.chipAtIndex != null ? an.chipAtIndex : an.toIndex;
var cx = xCenter(chipIdx);
var cy = (yAt(values[chipIdx]) + yLine) / 2;
var label = an.label || "";
var fs = 19;
var chipW = label.length * fs * 0.55 + 24;
var chipH = fs + 16;
svg.appendChild(el("rect", {
"class": "ghchart__anno-chip",
x: cx – chipW / 2, y: cy – chipH / 2, width: chipW, height: chipH, rx: chipH / 2
}));
var ct = el("text", {
"class": "ghchart__anno-chip-text",
x: cx, y: cy, dy: "0.34em", "text-anchor": "middle", "font-size": fs
});
ct.textContent = label;
svg.appendChild(ct);
});

wrap.appendChild(svg);
root.appendChild(wrap);
});

/* —- Caption (optional rich copy) ————————— */
if (config.caption) {
var cap = html("div", "ghchart__caption");
cap.innerHTML = config.caption;
root.appendChild(cap);
}
};

/* ================================================================
Stacked time-series bars
—————————————————————-
One bar per date, with each config.series value stacked in order.
Dated event annotations may fall between periods; set `after: true`
when an event on a week-ending date marks the boundary after its bar.
================================================================ */
renderers["stacked-bar"] = function (root, config) {
var labels = config.labels;
var dates = config.dates;
var series = config.series;
if (!Array.isArray(labels) || !labels.length ||
!Array.isArray(dates) || dates.length !== labels.length ||
!Array.isArray(series) || !series.length ||
series.some(function (s) {
return !s || typeof s.name !== "string" ||
typeof s.color !== "string" ||
!/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(s.color) ||
!Array.isArray(s.values) || s.values.length !== labels.length ||
s.values.some(function (v) { return !Number.isFinite(v) || v 0 &&
Date.parse(date + “T00:00:00Z”) <= Date.parse(dates[index – 1] + "T00:00:00Z");
}) ||
!Array.isArray(config.annotations || []) ||
(config.annotations || []).some(function (annotation) {
return !annotation || typeof annotation.at !== "string" ||
(annotation.label != null && typeof annotation.label !== "string") ||
(annotation.labelY != null && !Number.isFinite(annotation.labelY));
})) {
throw new Error("Stacked bar charts require matching labels, ISO dates, and non-negative finite values for each colored series.");
}

root.textContent = "";
root.setAttribute("role", "figure");
root.setAttribute("aria-label", config.title);

var title = html("div", "ghchart__headline");
title.textContent = config.title;
root.appendChild(title);

var legend = html("div", "ghchart__legend");
series.forEach(function (s) {
var item = html("span", "ghchart__legend-item");
var swatch = html("span", "ghchart__swatch");
swatch.style.backgroundColor = s.color;
if (config.legendSize) item.style.fontSize = config.legendSize + "px";
item.appendChild(swatch);
item.appendChild(document.createTextNode(s.name));
legend.appendChild(item);
});
root.appendChild(legend);

var tip = html("div", "ghchart__tooltip");
tip.setAttribute("role", "status");
tip.setAttribute("data-visible", "false");
var tipTitle = html("div", "ghchart__tt-title");
var tipBody = html("div", "ghchart__tt-body");
tip.appendChild(tipTitle);
tip.appendChild(tipBody);
root.appendChild(tip);

var hasXAxisTitle = !!config.xAxisTitle;
var bottomMargin = hasXAxisTitle ? 152 : 90;
var VB = { w: 1000, h: config.height || (hasXAxisTitle ? 560 : 498) };
var M = { top: 28, right: 24, bottom: bottomMargin, left: 155 };
var innerW = VB.w – M.left – M.right;
var innerH = VB.h – M.top – M.bottom;
var count = labels.length;
var axis = config.yAxis || {};
var yMin = axis.min != null ? axis.min : 0;
var yMax = axis.max;
var step = axis.step;
if (!Number.isFinite(yMin) || !Number.isFinite(yMax) || yMax <= yMin ||
!Number.isFinite(step) || step yMax || total < yMin; })) {
throw new Error("Stacked bar totals must fit within the configured yAxis range.");
}
function yAt(value) {
return M.top + innerH * (1 – (value – yMin) / (yMax – yMin));
}
function xCenter(index) { return M.left + innerW * (index + 0.5) / count; }
var slot = innerW / count;
var barW = Math.min(slot * 0.72, config.maxBarVB || 150);
var baseY = yAt(yMin);
var svg = el("svg", {
"class": "ghchart__plot ghchart__plot–bar",
viewBox: "0 0 " + VB.w + " " + VB.h,
preserveAspectRatio: "xMidYMid meet",
role: "img",
"aria-label": config.title + ". " + (axis.title || "") + ". Series: " +
series.map(function (s) { return s.name; }).join(", ") + ". " +
labels[0] + " through " + labels[count – 1]
});
var formatAxis = config.formatAxis || function (v) { return String(v); };

for (var tick = yMin; tick <= yMax + 1e-9; tick += step) {
var gy = yAt(tick);
svg.appendChild(el("line", {
"class": "ghchart__gridline", x1: M.left, y1: gy, x2: VB.w – M.right, y2: gy
}));
var yLabel = el("text", {
"class": "ghchart__axislabel ghchart__axislabel–y",
x: M.left – 16, y: gy, dy: "0.32em", "text-anchor": "end"
});
yLabel.textContent = formatAxis(tick);
svg.appendChild(yLabel);
}
svg.appendChild(el("line", {
"class": "ghchart__axisline",
x1: M.left, y1: baseY, x2: VB.w – M.right, y2: baseY
}));

var yTitleY = M.top + innerH / 2;
var yTitle = el("text", {
"class": "ghchart__axislabel ghchart__axislabel–y",
x: 26, y: yTitleY,
transform: "rotate(-90 26 " + yTitleY + ")",
"text-anchor": "middle"
});
yTitle.textContent = axis.title || "";
svg.appendChild(yTitle);

for (var xi = 0; xi < count; xi++) {
var labelX = xCenter(xi) + 7;
var labelY = baseY + 16;
var xLabel = el("text", {
"class": "ghchart__axislabel ghchart__axislabel–x",
x: labelX, y: labelY, "text-anchor": "end",
transform: "rotate(-45 " + labelX + " " + labelY + ")"
});
if (config.xLabelSize) xLabel.setAttribute("style", "font-size:" + config.xLabelSize + "px");
xLabel.textContent = labels[xi];
svg.appendChild(xLabel);
}
var xTitle = el("text", {
"class": "ghchart__axislabel ghchart__axislabel–x",
x: M.left + innerW / 2, y: VB.h – 8, "text-anchor": "middle"
});
xTitle.textContent = config.xAxisTitle || "";
svg.appendChild(xTitle);

var barsByIndex = labels.map(function () { return []; });
for (var bi = 0; bi < count; bi++) {
var cumulative = yMin;
for (var si = 0; si < series.length; si++) {
var value = series[si].values[bi];
var top = yAt(cumulative + value);
var bottom = yAt(cumulative);
var segment = el("rect", {
"class": "ghchart__bar",
x: xCenter(bi) – barW / 2, y: top, width: barW,
height: Math.max(0, bottom – top), fill: series[si].color
});
svg.appendChild(segment);
barsByIndex[bi].push(segment);
cumulative += value;
}
}

function annotationX(annotation) {
var target = Date.parse(annotation.at + "T00:00:00Z");
var parsed = dates.map(function (date) { return Date.parse(date + "T00:00:00Z"); });
if (!Number.isFinite(target) || target parsed[parsed.length – 1]) {
throw new Error(“Stacked bar annotation dates must fall within the plotted date range.”);
}
for (var di = 0; di < parsed.length; di++) {
if (target === parsed[di]) {
return xCenter(di) + (annotation.after && di < count – 1 ? slot / 2 : 0);
}
if (di parsed[di] && target VB.w – M.right ? “end” : “start”;
var text = el(“text”, {
“class”: “ghchart__stacked-annotation-label”,
x: anchor === “end” ? ax – 8 : ax + 8,
y: annotation.labelY != null
? clamp(yAt(annotation.labelY), M.top + 20, baseY – 12)
: M.top + 28,
“text-anchor”: anchor,
fill: annotation.color || “#1f2328”
});
text.textContent = annotation.label;
svg.appendChild(text);
}
});

var activeIndex = null;
var reduceMotion = window.matchMedia &&
window.matchMedia(“(prefers-reduced-motion: reduce)”).matches;
for (var hi = 0; hi < count; hi++) {
var hit = el("rect", {
"class": "ghchart__barhit",
x: M.left + slot * hi, y: M.top, width: slot, height: baseY – M.top,
tabindex: "0", role: "button",
"aria-label": labels[hi] + ": " + totals[hi].toLocaleString() + " total"
});
(function (index, hitTarget) {
function showTip() {
var reappearing = activeIndex === null;
activeIndex = index;
barsByIndex.forEach(function (barSegments, barIndex) {
barSegments.forEach(function (segment) {
segment.style.opacity = barIndex === index ? "1" : "0.42";
});
});
tipTitle.textContent = labels[index] + ", " + dates[index].slice(0, 4);
tipBody.textContent = "";
series.forEach(function (s) {
var row = html("div", "ghchart__tt-line");
row.textContent = s.name + ": " + s.values[index].toLocaleString();
tipBody.appendChild(row);
});
var totalRow = html("div", "ghchart__tt-line");
totalRow.textContent = "Total: " + totals[index].toLocaleString();
tipBody.appendChild(totalRow);
var anchor = hitTarget.getBoundingClientRect();
var rootRect = root.getBoundingClientRect();
var left = anchor.left + anchor.width / 2 – rootRect.left;
var top = anchor.top – rootRect.top – 12;
if (reappearing && !reduceMotion) 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 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-stargazer-slow-killed-queries”,
type: “stacked-bar”,
title: “Slow stargazer count queries per week, by source”,
dates: [“2026-05-05”, “2026-05-12”, “2026-05-19”, “2026-05-27”, “2026-06-03”, “2026-06-10”, “2026-06-17”, “2026-06-24”, “2026-07-01”, “2026-07-08”, “2026-07-15”, “2026-07-22”, “2026-07-29”, “2026-08-05”, “2026-08-12”, “2026-08-19”, “2026-08-26”, “2026-09-02”, “2026-09-09”, “2026-09-16”, “2026-09-23”, “2026-09-30”, “2026-10-07”],
labels: [“May 5”, “May 12”, “May 19”, “May 27”, “Jun 3”, “Jun 10”, “Jun 17”, “Jun 24”, “Jul 1”, “Jul 8”, “Jul 15”, “Jul 22”, “Jul 29”, “Aug 5”, “Aug 12”, “Aug 19”, “Aug 26”, “Sep 2”, “Sep 9”, “Sep 16”, “Sep 23”, “Sep 30”, “Oct 7”],
xLabelSize: 18,
legendSize: 14,
xAxisTitle: “”,
yAxis: {
min: 0,
max: 60000,
step: 10000,
title: “Slow or killed queries per week”
},
series: [
{
name: “REST API”,
color: “#0969da”,
values: [6283, 5466, 5692, 6743, 2735, 4655, 4655, 4992, 3850, 12840, 18049, 12957, 12764, 11390, 10796, 6564, 7653, 6116, 5357, 3824, 3192, 6722, 5620]
},
{
name: “GraphQL API”,
color: “#8250df”,
values: [5401, 6954, 8637, 7663, 4781, 4888, 6511, 8556, 14573, 16511, 37294, 23572, 778, 91, 28, 35, 58, 11, 7, 41, 1, 41, 57]
},
{
name: “Stargazers page on GitHub.com”,
color: “#bf8700”,
values: [135, 146, 231, 130, 240, 357, 226, 280, 175, 285, 393, 426, 674, 400, 336, 282, 233, 195, 228, 133, 0, 0, 0]
}
],
annotations: [
{
at: “2026-07-22”,
after: true,
label: “Access restricted”,
labelY: 54000
},
{
at: “2026-09-14”,
between: true,
label: “Stargazers page fix”,
labelY: 24000
}
],
formatAxis: function (v) { return Math.round(v).toLocaleString(); },
caption: “Weekly slow or killed queries by source · week ending May 5–October 7, 2026.”
};
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();
}
})();

What we learned

  1. A smaller response does not always mean a cheaper query. LIMIT and OFFSET reduced the number of results returned, but they did not reduce the grouping and sorting the database had already done. We had to look at the order in which the work happened.
  2. Start with what people are actually trying to do. Maintainers needed to compare trends. A list of identities was simply how the old endpoint happened to provide the underlying data.
  3. Return only what the use case needs. Anything more is data someone else can misuse. Once we understood the job, we could remove individual identities without taking away the information maintainers relied on.
  4. Be clear about the tradeoffs that remain. The new endpoint is safer and more predictable, but calendar boundaries, removed stars, and unusually dense weeks still affect what it can return. Those details are part of the API contract too.

Restricting the original endpoint addressed the immediate problem. Listening to maintainers gave us something better than what we started with: star history that’s easier to get, cheaper to serve, and safer for everyone who stars a project.

Get repository star history, then share feedback in the GitHub Community discussion.

The post From 2,500 requests to 24: Rebuilding star history without the stargazers appeared first on The GitHub Blog.

Source: GitHub Blog