<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>__PAGE_TITLE__</title>
<link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgdmlld0JveD0iMCAwIDMyIDMyIj48cmVjdCB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHJ4PSI3IiBmaWxsPSIjMmY1ZmQwIi8+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNC45MiwyNC4wMCkgc2NhbGUoMC4wMTA4LC0wLjAxMDgpIiBmaWxsPSIjZmZmZmZmIj48cGF0aCBkPSJNMTE4NCAwVjE0OEgxODc2VjBaTTE4NzYgMTQ4MFYxMzMySDE0MTdMNzUxIDBIMTczVjE0OEg2MzFMMTI5OCAxNDgwWiIvPjwvZz48L3N2Zz4K"
/>
<style>
:root {
--bg: #f6f7f9;
--card: #ffffff;
--border: #e4e7ec;
--border-strong: #d4d9e0;
--text: #1c2530;
--muted: #5d6b7c;
--faint: #98a3b1;
--accent: #2f6feb;
--accent-soft: rgba(47, 111, 235, 0.1);
--shadow:
0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 6px rgba(16, 24, 40, 0.05);
--shadow-lg: 0 8px 28px rgba(16, 24, 40, 0.14);
--grid-year: #e2e6ec;
--grid-month: #eef1f5;
--track: #e8ebf0;
--radius: 12px;
--font-sans:
-apple-system, BlinkMacSystemFont, "Segoe UI", Inter,
"Helvetica Neue", Arial, sans-serif;
--font-display: var(--font-sans);
color-scheme: light;
}
[data-theme="dark"] {
--bg: #0d1117;
--card: #151b23;
--border: #262d37;
--border-strong: #333c48;
--text: #e6ebf2;
--muted: #9aa7b6;
--faint: #5f6c7b;
--accent-soft: rgba(83, 140, 255, 0.13);
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 32px rgba(0, 0, 0, 0.55);
--grid-year: #232b35;
--grid-month: #1a212a;
--track: #2a323d;
color-scheme: dark;
}
[data-theme="wikipedia"] {
--bg: #ffffff;
--card: #ffffff;
--border: #c8ccd1;
--border-strong: #a2a9b1;
--text: #202122;
--muted: #54595d;
--faint: #72777d;
--accent: #3366cc;
--accent-soft: rgba(51, 102, 204, 0.1);
--shadow: none;
--shadow-lg: 0 4px 14px rgba(0, 0, 0, 0.18);
--grid-year: #c8ccd1;
--grid-month: #eaecf0;
--track: #e8e8e8;
--radius: 2px;
--font-sans: sans-serif;
--font-display: "Linux Libertine", "Georgia", "Times New Roman", serif;
color-scheme: light;
}
[data-theme="wikipedia"] .title-block h1 {
font-weight: 400;
font-size: 30px;
letter-spacing: normal;
}
[data-theme="wikipedia"] .btn,
[data-theme="wikipedia"] input[type="search"],
[data-theme="wikipedia"] input[type="number"],
[data-theme="wikipedia"] select,
[data-theme="wikipedia"] .legend .chip,
[data-theme="wikipedia"] .ctx-toggle {
border-radius: 2px;
}
[data-theme="wikipedia"] .legend .chip .dot {
border-radius: 0;
}
* {
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.45 var(--font-sans);
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 1200px;
margin: 0 auto;
padding: 28px 24px 60px;
display: flex;
flex-direction: column;
}
.wrap.wide {
max-width: none;
}
.mobile-nav {
order: 0;
}
header {
order: 1;
}
.toolbar {
order: 2;
}
.context-card {
order: 3;
}
.legend {
order: 4;
}
.chart-card {
order: 5;
}
footer {
order: 6;
}
.menu {
display: contents;
}
.mobile-nav,
.menu-backdrop {
display: none;
}
header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.title-block h1 {
margin: 0 0 6px;
font-family: var(--font-display);
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
display: flex;
align-items: center;
gap: 10px;
}
.title-block h1 a {
color: inherit;
text-decoration: none;
}
.title-block h1 a:hover {
color: var(--accent);
}
.title-block h1 svg {
width: 22px;
height: 22px;
color: var(--faint);
flex: none;
}
.title-block h1 img.owner {
width: 24px;
height: 24px;
border-radius: 6px;
flex: none;
object-fit: cover;
}
.stats {
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
color: var(--muted);
font-size: 13px;
}
.stats b {
color: var(--text);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.repo-desc {
margin: 8px 0 0;
max-width: 70ch;
color: var(--muted);
font-size: 14px;
line-height: 1.45;
}
.header-actions {
display: flex;
gap: 8px;
flex: none;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 7px 12px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
box-shadow: var(--shadow);
transition:
border-color 0.12s,
background 0.12s;
}
.btn:hover {
border-color: var(--border-strong);
}
.btn[aria-pressed="true"] {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-soft);
}
.btn svg {
width: 15px;
height: 15px;
}
.btn.icon {
padding: 7px 9px;
}
select.btn {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="%23888" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4.5 6 7.5 9 4.5"/></svg>');
background-repeat: no-repeat;
background-position: right 9px center;
padding-right: 26px;
}
.toolbar {
position: sticky;
top: 12px;
z-index: 30;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 12px 16px;
margin-bottom: 14px;
display: flex;
flex-wrap: wrap;
gap: 14px 22px;
align-items: center;
justify-content: space-between;
}
.ctl {
display: flex;
flex-direction: column;
gap: 4px;
}
.ctl > label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--faint);
}
.ctl-row {
display: flex;
align-items: center;
gap: 8px;
}
input[type="search"],
input[type="number"],
select {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 7px;
padding: 6px 9px;
font-size: 13px;
font-family: inherit;
outline: none;
transition:
border-color 0.12s,
box-shadow 0.12s;
}
select {
-webkit-appearance: none;
appearance: none;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="%23888" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4.5 6 7.5 9 4.5"/></svg>');
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 28px;
}
input[type="search"]:focus,
input[type="number"]:focus,
select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
input[type="search"] {
width: 190px;
}
input[type="number"] {
width: 70px;
}
.ctl .val {
min-width: 42px;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--text);
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 130px;
height: 4px;
border-radius: 2px;
background: var(--track);
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 15px;
height: 15px;
border-radius: 50%;
background: var(--accent);
border: 2.5px solid var(--card);
box-shadow: 0 1px 4px rgba(16, 24, 40, 0.3);
cursor: grab;
}
input[type="range"]::-moz-range-thumb {
width: 13px;
height: 13px;
border-radius: 50%;
background: var(--accent);
border: 2.5px solid var(--card);
box-shadow: 0 1px 4px rgba(16, 24, 40, 0.3);
cursor: grab;
}
.switch {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
gap: 8px;
padding-top: 3px;
}
.switch input {
position: absolute;
opacity: 0;
}
.switch .track {
width: 32px;
height: 18px;
border-radius: 9px;
background: var(--track);
transition: background 0.15s;
flex: none;
position: relative;
}
.switch .track::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--card);
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.3);
transition: transform 0.15s;
}
.switch input:checked + .track {
background: var(--accent);
}
.switch input:checked + .track::after {
transform: translateX(14px);
}
.switch span.lbl {
font-size: 13px;
color: var(--muted);
}
.toolbar .spacer {
display: none;
}
.showing {
font-size: 13px;
color: var(--muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.showing b {
color: var(--text);
}
.btn.subtle {
box-shadow: none;
background: transparent;
border-color: transparent;
color: var(--muted);
}
.btn.subtle:hover {
color: var(--text);
background: var(--accent-soft);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-bottom: 14px;
}
.legend .chip {
display: inline-flex;
align-items: center;
gap: 7px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 99px;
padding: 4px 12px 4px 9px;
font-size: 12.5px;
color: var(--muted);
cursor: pointer;
transition:
border-color 0.12s,
box-shadow 0.12s,
opacity 0.12s;
user-select: none;
}
.legend .chip .dot {
width: 9px;
height: 9px;
border-radius: 3px;
flex: none;
}
.legend .chip .n {
color: var(--faint);
font-variant-numeric: tabular-nums;
}
.legend .chip:hover {
border-color: var(--border-strong);
}
.legend .chip.on {
border-color: var(--accent);
color: var(--text);
box-shadow: 0 0 0 2.5px var(--accent-soft);
}
.legend.has-sel .chip:not(.on) {
opacity: 0.55;
}
.legend .chip.more {
font-weight: 600;
color: var(--accent);
padding: 4px 12px;
}
.legend.has-sel .chip.more {
opacity: 1;
}
.context-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 10px 4px 6px;
margin-bottom: 14px;
}
.context-card .hint {
font-size: 11px;
color: var(--faint);
margin: 0 0 2px;
padding: 0 8px;
display: flex;
align-items: center;
justify-content: space-between;
}
.context-card .hint-right {
display: flex;
align-items: center;
gap: 10px;
flex: none;
}
.ctx-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 18px;
padding: 0;
border: 1px solid transparent;
border-radius: 5px;
background: transparent;
color: var(--faint);
cursor: pointer;
transition:
color 0.12s,
background 0.12s,
border-color 0.12s;
}
.ctx-toggle:hover {
color: var(--muted);
background: var(--accent-soft);
}
.ctx-toggle[aria-pressed="true"] {
color: var(--accent);
border-color: var(--border-strong);
}
.ctx-toggle svg {
width: 14px;
height: 14px;
}
#context {
display: block;
width: 100%;
cursor: crosshair;
touch-action: none;
}
.chart-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 8px 4px 4px;
overflow: hidden;
}
#chart {
display: block;
width: 100%;
}
#chart a {
cursor: pointer;
}
#chart g.crow {
cursor: pointer;
}
#chart .row-hit {
fill: transparent;
}
#chart g.crow:hover .row-hit {
fill: var(--accent-soft);
}
#chart g.crow:hover .chev {
stroke: var(--accent);
}
.empty {
text-align: center;
padding: 70px 20px;
color: var(--muted);
}
.empty .big {
font-size: 16px;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
}
#tooltip {
position: fixed;
z-index: 100;
pointer-events: none;
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow-lg);
padding: 12px 14px;
max-width: 290px;
opacity: 0;
transform: translateY(4px);
transition:
opacity 0.1s,
transform 0.1s;
}
#tooltip.show {
opacity: 1;
transform: none;
}
#tooltip .tt-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
#tooltip .tt-head img,
#tooltip .tt-head .ph {
width: 36px;
height: 36px;
border-radius: 50%;
flex: none;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 13px;
color: #fff;
}
#tooltip .tt-name {
font-weight: 600;
font-size: 14px;
}
#tooltip .tt-login {
font-size: 12px;
color: var(--muted);
}
#tooltip .tt-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 2px 12px;
font-size: 12.5px;
}
#tooltip .tt-grid dt {
color: var(--muted);
}
#tooltip .tt-grid dd {
margin: 0;
font-variant-numeric: tabular-nums;
text-align: right;
font-weight: 500;
}
#tooltip .tt-members {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--muted);
line-height: 1.5;
}
#tooltip .tt-affil {
margin: 8px 0;
padding: 8px 0;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 3px;
font-size: 12.5px;
}
#tooltip .tt-affil-row {
display: flex;
align-items: center;
gap: 7px;
}
#tooltip .tt-affil-row .dot {
width: 9px;
height: 9px;
border-radius: 3px;
flex: none;
}
#tooltip .tt-spark {
margin-top: 8px;
}
#tooltip .badge {
display: inline-block;
font-size: 11px;
font-weight: 600;
padding: 1px 8px;
border-radius: 99px;
margin-left: 4px;
vertical-align: 1px;
}
footer {
margin-top: 22px;
text-align: center;
font-size: 12px;
color: var(--faint);
}
footer a {
color: var(--muted);
}
@media (max-width: 760px) {
.wrap {
padding: 0 12px 40px;
}
header {
flex-direction: column;
padding-top: 14px;
}
input[type="search"] {
width: 100%;
}
#wide-toggle {
display: none;
}
.mobile-nav {
display: flex;
align-items: center;
gap: 10px;
position: sticky;
top: 0;
z-index: 40;
margin: 0 -12px;
padding: 8px 12px;
background: var(--card);
border-bottom: 1px solid var(--border);
}
.mobile-nav .mn-title {
flex: 1;
min-width: 0;
font-family: var(--font-display);
font-weight: 700;
font-size: 15px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.burger {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 40px;
height: 34px;
padding: 0;
background: var(--card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
}
.burger svg {
width: 18px;
height: 18px;
}
.burger .ic-close {
display: none;
}
body.menu-open .burger {
border-color: var(--accent);
color: var(--accent);
}
body.menu-open .burger .ic-open {
display: none;
}
body.menu-open .burger .ic-close {
display: block;
}
.menu {
display: block;
position: fixed;
top: var(--nav-h, 52px);
left: 0;
right: 0;
z-index: 39;
max-height: calc(100dvh - var(--nav-h, 52px));
overflow-y: auto;
padding: 16px 14px 20px;
background: var(--card);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-lg);
transform: translateY(-12px);
opacity: 0;
visibility: hidden;
transition:
transform 0.18s ease,
opacity 0.18s ease,
visibility 0.18s;
}
body.menu-open .menu {
transform: none;
opacity: 1;
visibility: visible;
}
.menu-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 38;
background: rgba(0, 0, 0, 0.45);
opacity: 0;
visibility: hidden;
transition:
opacity 0.18s,
visibility 0.18s;
}
body.menu-open .menu-backdrop {
opacity: 1;
visibility: visible;
}
.toolbar {
position: static;
flex-direction: column;
align-items: stretch;
gap: 16px;
margin: 0;
padding: 0;
border: none;
border-radius: 0;
box-shadow: none;
}
.toolbar .ctl,
.toolbar select,
.toolbar .ctl-row {
width: 100%;
}
.toolbar .showing {
white-space: normal;
}
.legend {
margin: 4px 0 0;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.context-card {
position: sticky;
top: var(--nav-h, 52px);
z-index: 20;
}
}
@media print {
.toolbar,
.header-actions,
.context-card,
footer .no-print {
display: none !important;
}
body {
background: #fff;
}
.chart-card {
border: none;
box-shadow: none;
}
}
</style>
</head>
<body>
<div class="wrap">
<div class="mobile-nav" id="mobile-nav">
<span class="mn-title" id="mn-title"></span>
<button
class="burger"
id="burger"
type="button"
aria-expanded="false"
aria-controls="menu"
aria-label="Filters and options"
>
<svg
class="ic-open"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
>
<path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11" />
</svg>
<svg
class="ic-close"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
>
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
<header>
<div class="title-block">
<h1 id="repo-title"></h1>
<div class="stats" id="stats"></div>
<p class="repo-desc" id="repo-desc" hidden></p>
</div>
<div class="header-actions">
<button
class="btn"
id="wide-toggle"
type="button"
aria-pressed="false"
title="Toggle full window width"
>
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M1.5 8h13M4 5 1.5 8 4 11M12 5l2.5 3-2.5 3" />
</svg>
Full width
</button>
<button class="btn" id="dl-svg" title="Download current view as SVG">
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M8 2v8m0 0L5 7m3 3 3-3M2.5 12.5h11" />
</svg>
SVG
</button>
<button class="btn" id="dl-png" title="Download current view as PNG">
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M8 2v8m0 0L5 7m3 3 3-3M2.5 12.5h11" />
</svg>
PNG
</button>
<select
class="btn"
id="theme"
title="Theme"
aria-label="Theme"
></select>
</div>
</header>
<div class="menu" id="menu">
<div class="toolbar">
<div class="ctl" id="ctl-rows" hidden>
<label for="rows">Rows</label>
<select id="rows">
<option value="contributors">Contributors</option>
<option value="affiliations">Affiliations</option>
</select>
</div>
<div class="ctl">
<label for="search">Search</label>
<input
type="search"
id="search"
placeholder="Name or username…"
autocomplete="off"
/>
</div>
<div class="ctl">
<label for="min-range">Min commits</label>
<div class="ctl-row">
<input
type="range"
id="min-range"
min="0"
max="100"
value="0"
step="1"
/>
<input
type="number"
id="min-num"
min="1"
value="1"
aria-label="Minimum commits"
/>
</div>
</div>
<div class="ctl">
<label for="topn">Show top</label>
<div class="ctl-row">
<input type="number" id="topn" min="1" step="5" />
<span class="val" id="topn-of"></span>
</div>
</div>
<div class="ctl">
<label for="sort">Order by</label>
<select id="sort">
<option value="first">First commit</option>
<option value="last">Latest commit</option>
<option value="commits">Most commits</option>
<option value="duration">Longest active</option>
<option value="name">Name</option>
</select>
</div>
<div class="ctl">
<label for="color">Colour</label>
<select id="color">
<option value="activity">Activity heat</option>
<option value="solid">Solid</option>
<option value="year">Year joined</option>
<option value="group" id="opt-group" hidden>Group</option>
</select>
</div>
<div class="ctl">
<label> </label>
<label class="switch">
<input type="checkbox" id="bots" />
<span class="track"></span>
<span class="lbl">Bots</span>
</label>
</div>
<div class="ctl" id="ctl-coauthors" hidden>
<label> </label>
<label class="switch">
<input type="checkbox" id="coauthors" />
<span class="track"></span>
<span class="lbl">Co-authors</span>
</label>
</div>
<div class="ctl" id="ctl-releases" hidden>
<label> </label>
<label class="switch">
<input type="checkbox" id="releases" />
<span class="track"></span>
<span class="lbl">Releases</span>
</label>
</div>
<div class="spacer"></div>
<div class="showing" id="showing"></div>
<button
class="btn subtle"
id="expand-all"
title="Expand or collapse every visible row"
>
Expand all
</button>
<button class="btn subtle" id="reset" title="Reset all filters">
Reset
</button>
</div>
<div class="legend" id="legend" hidden></div>
</div>
<div class="context-card">
<p class="hint">
<span
>Repository activity — drag to zoom the timeline, double-click to
reset</span
>
<span class="hint-right">
<span id="brush-label"></span>
<button
class="ctx-toggle"
id="align-ctx"
type="button"
aria-pressed="false"
title="Align the activity plot with the rows below"
>
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M1.5 8h13M4 5 1.5 8 4 11M12 5l2.5 3-2.5 3" />
</svg>
</button>
</span>
</p>
<svg id="context" height="64"></svg>
</div>
<div class="chart-card">
<svg id="chart"></svg>
<div class="empty" id="empty" style="display: none">
<div class="big">Nothing matches these filters</div>
<div>
Try clearing the search or lowering the minimum commit filter.
</div>
</div>
</div>
<footer id="footer"></footer>
</div>
<div class="menu-backdrop" id="menu-backdrop"></div>
<div id="tooltip" role="tooltip"></div>
<script id="data" type="application/json">
__DATA__
</script>
<script>
"use strict";
const $ = (s) => document.querySelector(s);
const DATA = JSON.parse($("#data").textContent);
const REPO = DATA.repo;
const ALL = DATA.contributors;
ALL.forEach((c, i) => {
c._id = i;
});
const HAS_COAUTHORS = ALL.some((c) => c.co_commits > 0);
if (HAS_COAUTHORS) {
ALL.forEach((c) => {
c._monthsAll = c.months;
c._commitsAll = c.commits;
const co = c.co_months || [];
c._monthsAuth = c.months.map((v, i) => v - (co[i] || 0));
c._commitsAuth = c.commits - (c.co_commits || 0);
});
}
function applyCoauthors() {
if (!HAS_COAUTHORS) return;
const on = state.coauthors;
for (const c of ALL) {
c.months = on ? c._monthsAll : c._monthsAuth;
c.commits = on ? c._commitsAll : c._commitsAuth;
}
}
const ACCENT = DATA.accent || "#2f6feb";
const GROUP_PALETTE = [
"#4269d0",
"#e7a13d",
"#ff725c",
"#6cc5b0",
"#3ca951",
"#ff8ab7",
"#a463f2",
"#97bbf5",
"#9c6b4e",
"#9498a0",
"#2f7f8f",
"#c65b8a",
"#d62728",
"#17becf",
"#7570b3",
"#66a61e",
"#e6ab02",
"#e7298a",
"#1f9e89",
"#b15928",
];
const YEAR_PALETTE = [
"#4269d0",
"#3ca951",
"#e7a13d",
"#ff725c",
"#a463f2",
"#2f7f8f",
"#c65b8a",
"#6cc5b0",
];
const OTHER_COLOR = "#9aa3ad";
const GROUP_COUNTS = (() => {
const m = new Map();
const bump = (g) => g && m.set(g, (m.get(g) || 0) + 1);
for (const c of ALL) {
if (c.bot) continue;
bump(c.group);
if (c.month_groups) {
const seen = new Set();
for (const g of c.month_groups)
if (g && g !== c.group && !seen.has(g)) {
seen.add(g);
bump(g);
}
}
}
return [...m.entries()].sort((a, b) => b[1] - a[1]);
})();
const GROUPS = GROUP_COUNTS.map(([g]) => g);
const GROUP_COLOR = Object.fromEntries(
GROUP_COUNTS.map(([g], i) => [
g,
GROUP_PALETTE[i % GROUP_PALETTE.length],
]),
);
if (GROUPS.length) $("#opt-group").hidden = false;
const HUMANS = ALL.filter((c) => !c.bot);
const MAX_COMMITS = maxOf(
ALL.map((c) => c.commits),
1,
);
const SANS =
"-apple-system, 'Segoe UI', Inter, 'Helvetica Neue', Arial, sans-serif";
const BAND_PALETTE = [
"#ff0000",
"#0000ff",
"#00a000",
"#ff8c00",
"#9400d3",
"#009b9b",
"#ff1493",
"#76b900",
"#a0522d",
"#1e90ff",
"#e6a800",
"#dc143c",
];
const GROUP_COLOR_FLAT = Object.fromEntries(
GROUP_COUNTS.map(([g], i) => [
g,
BAND_PALETTE[i % BAND_PALETTE.length],
]),
);
function gcol(group) {
return (
(theme().flat ? GROUP_COLOR_FLAT[group] : GROUP_COLOR[group]) ||
OTHER_COLOR
);
}
const THEMES = {
light: {
label: "Light",
text: "#1c2530",
muted: "#5d6b7c",
faint: "#98a3b1",
gridYear: "#e2e6ec",
gridMonth: "#eef1f5",
track: "#e8ebf0",
release: "#6b7a99",
card: "#ffffff",
ctxArea: "#c9d7f5",
ctxLine: "#7d9ce8",
dim: "rgba(120,132,148,.18)",
font: SANS,
flat: false,
},
dark: {
label: "Dark",
text: "#e6ebf2",
muted: "#9aa7b6",
faint: "#5f6c7b",
gridYear: "#232b35",
gridMonth: "#1a212a",
track: "#2a323d",
release: "#8893ad",
card: "#151b23",
ctxArea: "#23344f",
ctxLine: "#4a6da8",
dim: "rgba(120,132,148,.14)",
font: SANS,
flat: false,
},
wikipedia: {
label: "Wikipedia",
text: "#202122",
muted: "#54595d",
faint: "#72777d",
gridYear: "#c8ccd1",
gridMonth: "#eaecf0",
track: "#e8e8e8",
release: "#000000",
card: "#ffffff",
ctxArea: "#cdd9f2",
ctxLine: "#5b81d4",
dim: "rgba(120,132,148,.16)",
font: "sans-serif",
flat: true,
},
};
(function registerCustomThemes() {
const extra = DATA.themes || [];
if (!extra.length) return;
const css = extra
.map((t) => {
const body = Object.entries(t.css || {})
.map(([k, v]) => `${k}:${v};`)
.join("");
return `[data-theme="${t.id}"]{${body}}`;
})
.join("\n");
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
for (const t of extra) THEMES[t.id] = { ...t.chart, label: t.label };
})();
const THEME_ORDER = (
DATA.themeOrder && DATA.themeOrder.length
? DATA.themeOrder
: ["light", "dark", "wikipedia"]
).filter((t) => THEMES[t]);
function themeName() {
const t = document.documentElement.dataset.theme;
return THEMES[t] ? t : "light";
}
function theme() {
return THEMES[themeName()];
}
function bandColor(c) {
const k = c.isGroup ? hashHue(c.group) : c._id;
return BAND_PALETTE[k % BAND_PALETTE.length];
}
function setTheme(name) {
if (!THEMES[name]) name = "light";
document.documentElement.dataset.theme = name;
localStorage.setItem("cg-theme", name);
const sel = $("#theme");
if (sel) sel.value = name;
state.releases = THEMES[name].flat && HAS_RELEASES;
syncControls();
renderAll();
}
function buildThemeMenu() {
const sel = $("#theme");
sel.innerHTML = THEME_ORDER.filter((t) => THEMES[t])
.map((t) => `<option value="${t}">${esc(THEMES[t].label)}</option>`)
.join("");
}
const fmtNum = (n) => n.toLocaleString("en-US");
const MONTHS_ABBR = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
function tsToDate(ts) {
return new Date(ts * 1000);
}
function fmtMonthYear(ts) {
const d = tsToDate(ts);
return MONTHS_ABBR[d.getUTCMonth()] + " " + d.getUTCFullYear();
}
function fmtDate(ts) {
const d = tsToDate(ts);
return (
d.getUTCFullYear() +
"-" +
String(d.getUTCMonth() + 1).padStart(2, "0") +
"-" +
String(d.getUTCDate()).padStart(2, "0")
);
}
function monthIndex(ts) {
const d = tsToDate(ts);
return (d.getUTCFullYear() - 1970) * 12 + d.getUTCMonth();
}
function monthStartTs(mi) {
return (
Date.UTC(1970 + Math.floor(mi / 12), ((mi % 12) + 12) % 12, 1) / 1000
);
}
function esc(s) {
return String(s)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
function initials(name) {
const w = name.trim().split(/\s+/);
return (
(w[0] || "?")[0] + (w.length > 1 ? w[w.length - 1][0] : "")
).toUpperCase();
}
function maxOf(a, init) {
let m = init;
for (const x of a) if (x > m) m = x;
return m;
}
function minOf(a, init) {
let m = init;
for (const x of a) if (x < m) m = x;
return m;
}
function hashHue(s) {
let h = 2166136261;
for (const c of s) {
h ^= c.charCodeAt(0);
h = Math.imul(h, 16777619);
}
return (h >>> 0) % 360;
}
const measureCtx = document.createElement("canvas").getContext("2d");
function textWidth(s, px, weight) {
measureCtx.font =
(weight || 400) +
" " +
px +
"px -apple-system, 'Segoe UI', Inter, Arial, sans-serif";
return measureCtx.measureText(s).width;
}
const UNAFFILIATED = DATA.unaffiliated || "Unaffiliated";
const HAS_GROUPS = GROUPS.length > 0;
const DEFAULT_MODE =
DATA.byAffiliation && HAS_GROUPS ? "affiliations" : "contributors";
const DEFAULT_TOP = Math.min(50, HUMANS.length);
const DEFAULT_COLOR = HAS_GROUPS ? "group" : "activity";
const state = {
mode: DEFAULT_MODE,
q: "",
min: 1,
top: DEFAULT_TOP,
sort: "first",
color: DEFAULT_COLOR,
bots: false,
coauthors: true, releases: false, align: false, wide: false, t0: null,
t1: null,
gsel: new Set(),
expanded: new Set(), legendExpanded: false, };
const HAS_RELEASES = (DATA.repo.releases || []).length > 0;
if (HAS_GROUPS) $("#ctl-rows").hidden = false;
if (HAS_COAUTHORS) $("#ctl-coauthors").hidden = false;
if (HAS_RELEASES) $("#ctl-releases").hidden = false;
function aggregateByGroup(rows) {
const map = new Map();
const aggFor = (g) => {
let a = map.get(g);
if (!a) {
a = {
name: g,
group: g,
isGroup: true,
commits: 0,
first: Infinity,
last: -Infinity,
members: 0,
login: null,
avatar: null,
url: null,
bot: false,
_m: new Map(),
_names: [],
_seen: new Set(),
};
map.set(g, a);
}
return a;
};
const join = (a, name, n) => {
if (!a._seen.has(name)) {
a._seen.add(name);
a.members++;
}
a._names.push([name, n]);
};
for (const c of rows) {
const dft = c.group || UNAFFILIATED;
if (!c.month_groups) {
const a = aggFor(dft);
a.commits += c.commits;
a.first = Math.min(a.first, c.first);
a.last = Math.max(a.last, c.last);
join(a, c.name, c.commits);
c.months.forEach((v, i) => {
if (v) {
const m = c.m0 + i;
a._m.set(m, (a._m.get(m) || 0) + v);
}
});
} else {
const per = new Map();
c.months.forEach((v, i) => {
if (!v) return;
const g = c.month_groups[i] || dft;
const a = aggFor(g);
const m = c.m0 + i;
a._m.set(m, (a._m.get(m) || 0) + v);
a.commits += v;
a.first = Math.min(a.first, monthStartTs(m));
a.last = Math.max(a.last, monthStartTs(m));
per.set(g, (per.get(g) || 0) + v);
});
for (const [g, n] of per) join(aggFor(g), c.name, n);
}
}
const out = [];
for (const a of map.values()) {
const ms = [...a._m.keys()];
a.m0 = ms.length ? minOf(ms, Infinity) : monthIndex(a.first);
const m1 = ms.length ? maxOf(ms, -Infinity) : a.m0;
a.months = new Array(m1 - a.m0 + 1).fill(0);
a._m.forEach((v, m) => {
a.months[m - a.m0] = v;
});
a._names.sort((x, y) => y[1] - x[1]);
a.member_names = a._names.slice(0, 8).map((x) => x[0]);
delete a._m;
delete a._names;
delete a._seen;
out.push(a);
}
return out;
}
const GSEP = String.fromCharCode(31); function readHash() {
if (!location.hash) return;
try {
const p = new URLSearchParams(location.hash.slice(1));
if (p.has("q")) state.q = p.get("q");
if (p.has("min")) state.min = +p.get("min") || 1;
if (p.has("top")) state.top = +p.get("top") || DEFAULT_TOP;
if (p.has("sort")) state.sort = p.get("sort");
if (p.has("color")) state.color = p.get("color");
if (p.has("bots")) state.bots = p.get("bots") === "1";
if (p.has("co")) state.coauthors = p.get("co") !== "0";
if (p.has("align")) state.align = p.get("align") === "1";
if (p.has("wide")) state.wide = p.get("wide") === "1";
if (p.has("t0")) state.t0 = +p.get("t0");
if (p.has("t1")) state.t1 = +p.get("t1");
if (p.has("g"))
state.gsel = new Set(
p
.get("g")
.split(GSEP)
.filter((g) => GROUPS.includes(g)),
);
if (p.has("rows") && HAS_GROUPS)
state.mode =
p.get("rows") === "affiliations"
? "affiliations"
: "contributors";
} catch (e) {}
}
let hashTimer;
function writeHash() {
clearTimeout(hashTimer);
hashTimer = setTimeout(() => {
const p = new URLSearchParams();
if (state.q) p.set("q", state.q);
if (state.min > 1) p.set("min", state.min);
if (state.top !== DEFAULT_TOP) p.set("top", state.top);
if (state.sort !== "first") p.set("sort", state.sort);
if (state.color !== DEFAULT_COLOR) p.set("color", state.color);
if (state.bots) p.set("bots", "1");
if (HAS_COAUTHORS && !state.coauthors) p.set("co", "0");
if (state.align) p.set("align", "1");
if (state.wide) p.set("wide", "1");
if (state.t0 != null) {
p.set("t0", Math.round(state.t0));
p.set("t1", Math.round(state.t1));
}
if (state.gsel.size) p.set("g", [...state.gsel].join(GSEP));
if (state.mode !== DEFAULT_MODE) p.set("rows", state.mode);
const s = p.toString();
history.replaceState(
null,
"",
s ? "#" + s : location.pathname + location.search,
);
}, 200);
}
const minRange = $("#min-range");
const LOG_MAX = Math.log(Math.max(2, MAX_COMMITS));
function sliderToMin(v) {
return Math.max(1, Math.round(Math.exp((v / 100) * LOG_MAX)));
}
function minToSlider(m) {
return Math.round((100 * Math.log(Math.max(1, m))) / LOG_MAX);
}
function domain() {
const t0 = state.t0 != null ? state.t0 : REPO.first;
const t1 = state.t1 != null ? state.t1 : REPO.last;
return [t0, t1];
}
function affMode() {
return state.mode === "affiliations";
}
function hasCommits(c) {
return c.commits > 0;
}
function rowMatches(c, q, t0, t1) {
return (
c.commits >= state.min &&
c.last >= t0 &&
c.first <= t1 &&
(!q ||
c.name.toLowerCase().includes(q) ||
(c.login || "").toLowerCase().includes(q) ||
(c.group || "").toLowerCase().includes(q))
);
}
function filtered() {
const [t0, t1] = domain();
const q = state.q.trim().toLowerCase();
const aff = affMode();
let base = ALL.filter((c) => state.bots || !c.bot);
if (aff) base = aggregateByGroup(base);
let rows = base.filter(
(c) => rowMatches(c, q, t0, t1) && (aff || matchesGroupFilter(c)),
);
const eligible = rows.length;
const total = base.filter(hasCommits).length;
if (rows.length > state.top) {
rows = rows
.slice()
.sort((a, b) => b.commits - a.commits)
.slice(0, state.top);
}
rows = rows.slice().sort(comparator(state.sort));
return { rows, eligible, total };
}
function smoothMonths(months) {
return months.map(
(v, i) => (2 * v + (months[i - 1] || 0) + (months[i + 1] || 0)) / 4,
);
}
const _monthlyMax = {};
function globalMonthlyMax() {
const key = affMode() ? "aff" : "con";
if (_monthlyMax[key] != null) return _monthlyMax[key];
const rows = affMode()
? aggregateByGroup(ALL.filter((c) => !c.bot))
: ALL;
let m = 1;
for (const c of rows) for (const v of c.months) if (v > m) m = v;
_monthlyMax[key] = m;
return m;
}
function comparator(key) {
switch (key) {
case "last":
return (a, b) => b.last - a.last || b.commits - a.commits;
case "commits":
return (a, b) => b.commits - a.commits;
case "duration":
return (a, b) => b.last - b.first - (a.last - a.first);
case "name":
return (a, b) => a.name.localeCompare(b.name);
default:
return (a, b) => a.first - b.first || b.commits - a.commits;
}
}
function rowColor(c) {
if (c.isGroup) return gcol(c.group);
if (state.color === "year") {
const y0 = tsToDate(REPO.first).getUTCFullYear();
const y = tsToDate(c.first).getUTCFullYear();
return YEAR_PALETTE[
((y - y0) % YEAR_PALETTE.length) +
(y < y0 ? YEAR_PALETTE.length : 0)
];
}
if (state.color === "group")
return c.group ? gcol(c.group) : OTHER_COLOR;
if (theme().flat) return bandColor(c);
return ACCENT; }
function monthColor(c, mi, fallback) {
if (!c.month_groups) return fallback;
const g = c.month_groups[mi];
return g ? gcol(g) : OTHER_COLOR;
}
function affiliationSegments(c) {
if (!c.month_groups)
return c.group
? [{ group: c.group, first: c.first, last: c.last }]
: [];
const segs = [];
let cur = null;
c.month_groups.forEach((g, i) => {
if (!g) {
cur = null;
return;
}
const ts = monthStartTs(c.m0 + i);
if (cur && cur.group === g) cur.last = ts;
else segs.push((cur = { group: g, first: ts, last: ts }));
});
return segs;
}
function segYears(s) {
const y0 = tsToDate(s.first).getUTCFullYear(),
y1 = tsToDate(s.last).getUTCFullYear();
return y0 === y1 ? `${y0}` : `${y0}–${y1}`;
}
const ANY_MULTI_AFFIL = HUMANS.some(
(c) => affiliationSegments(c).length > 1,
);
function rowBars(c) {
const months = c.months,
n = months.length;
const sel = state.gsel;
const split = !!c.month_groups;
const groupAt = (i) => (split ? c.month_groups[i] : c.group) || null;
const bars = [];
let i = 0;
while (i < n) {
const g = groupAt(i);
if (split && sel.size && !(g && sel.has(g))) {
i++;
continue;
}
let j = split ? i : n - 1;
if (split) while (j + 1 < n && groupAt(j + 1) === g) j++;
let a = -1,
b = -1;
for (let k = i; k <= j; k++)
if (months[k] > 0) {
if (a < 0) a = k;
b = k;
}
if (a >= 0)
bars.push({
a,
b,
group: g,
color: split ? gcol(g) : rowColor(c),
});
i = j + 1;
}
return bars;
}
function matchesGroupFilter(c) {
if (!state.gsel.size) return true;
if (c.group && state.gsel.has(c.group)) return true;
return c.month_groups
? c.month_groups.some((g) => g && state.gsel.has(g))
: false;
}
function shownCommits(c) {
if (!state.gsel.size || !c.month_groups) return c.commits;
let t = 0;
for (let i = 0; i < c.months.length; i++) {
const g = c.month_groups[i];
if (g && state.gsel.has(g)) t += c.months[i];
}
return t;
}
function timeTicks(t0, t1) {
const spanDays = (t1 - t0) / 86400;
const spanYears = spanDays / 365.25;
const y0 = tsToDate(t0).getUTCFullYear(),
y1 = tsToDate(t1).getUTCFullYear();
const major = [],
minor = [];
if (spanYears > 2.2) {
const step = Math.max(1, Math.ceil(spanYears / 11));
for (let y = y0; y <= y1 + 1; y++) {
if ((y - y0) % step) continue;
const ts = Date.UTC(y, 0, 1) / 1000;
if (ts >= t0 && ts <= t1) major.push([ts, String(y)]);
}
const minorMonths = spanYears <= 5 ? 1 : spanYears <= 11 ? 3 : 12;
for (let m = (y0 - 1970) * 12; m <= (y1 + 1 - 1970) * 12 + 11; m++) {
if (m % minorMonths) continue;
const ts = monthStartTs(m);
if (ts >= t0 && ts <= t1 && !major.some(([t]) => t === ts))
minor.push(ts);
}
} else {
const step = spanDays > 500 ? 3 : spanDays > 240 ? 2 : 1;
let lastYear = -1;
for (let m = (y0 - 1970) * 12; m <= (y1 + 1 - 1970) * 12 + 11; m++) {
const ts = monthStartTs(m);
if (ts < t0 || ts > t1) continue;
if (m % step === 0) {
const year = 1970 + Math.floor(m / 12);
const lbl =
MONTHS_ABBR[((m % 12) + 12) % 12] +
(year !== lastYear ? " " + year : "");
lastYear = year;
major.push([ts, lbl]);
} else minor.push(ts);
}
}
return { major, minor };
}
const ROW_H = 28,
BAR_H = 13,
AVATAR = 20;
const NAME_SIZE = 12.5,
COUNT_W = 62,
MARGIN_R = 14;
const EXP_H = 108; let currentRows = [];
let rowLayout = [];
function rowKey(c) {
return c.isGroup ? "g:" + c.group : "c:" + c._id;
}
function isExpanded(c) {
return state.expanded.has(rowKey(c));
}
function rowAtY(yPix) {
for (let i = 0; i < rowLayout.length; i++) {
const l = rowLayout[i];
if (yPix >= l.y && yPix < l.y + l.h) return i;
}
return -1;
}
function computeLayout() {
const width = document.querySelector(".chart-card").clientWidth - 8;
const names = affMode()
? [UNAFFILIATED, ...GROUPS]
: ALL.filter((c) => state.bots || !c.bot).map((c) => c.name);
let maxNameW = 60;
for (const nm of names) {
const w = textWidth(nm, NAME_SIZE, 500);
if (w > maxNameW) maxNameW = w;
}
const labelW = Math.min(
Math.max(maxNameW + AVATAR + 56, 140),
Math.max(170, width * 0.34),
);
const chartX = labelW;
const chartW = width - labelW - COUNT_W - MARGIN_R;
return { width, labelW, chartX, chartW };
}
function paddedDomain(d0, d1) {
const pad = Math.max(86400, (d1 - d0) * 0.012);
return [d0 - pad, d1 + pad];
}
function renderChart() {
const { rows, eligible, total } = filtered();
currentRows = rows;
const svg = $("#chart");
const th = theme();
const barH = th.flat ? Math.round(ROW_H * 0.8) : BAR_H;
const showReleases = HAS_RELEASES && state.releases;
$("#empty").style.display = rows.length ? "none" : "";
if (!rows.length) {
svg.innerHTML = "";
svg.setAttribute("height", 0);
rowLayout = [];
updateShowing(rows, eligible, total);
updateExpandBtn();
return;
}
const [d0raw, d1raw] = domain();
const [t0, t1] = paddedDomain(d0raw, d1raw);
const nameSize = NAME_SIZE;
const { width, labelW, chartX, chartW } = computeLayout();
const marginR = MARGIN_R;
const topPad = 26,
axisH = 30 + (showReleases ? 18 : 0);
rowLayout = [];
let yacc = topPad;
rows.forEach((c) => {
const h = ROW_H + (isExpanded(c) ? EXP_H : 0);
rowLayout.push({ y: yacc, h });
yacc += h;
});
const bodyBot = yacc;
const height = bodyBot + axisH;
const sx = (ts) => chartX + ((ts - t0) / (t1 - t0)) * chartW;
svg.setAttribute("width", width);
svg.setAttribute("height", height);
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
svg.setAttribute("font-family", th.font);
const parts = [];
const gridTop = topPad - 12,
gridBot = bodyBot + 6;
const ticks = timeTicks(t0, t1);
const showGrid = !th.flat && !showReleases;
if (showGrid)
for (const ts of ticks.minor)
parts.push(
`<line x1="${sx(ts)}" y1="${gridTop}" x2="${sx(ts)}" y2="${gridBot}" stroke="${th.gridMonth}"/>`,
);
for (const [ts, lbl] of ticks.major) {
if (showGrid)
parts.push(
`<line x1="${sx(ts)}" y1="${gridTop}" x2="${sx(ts)}" y2="${gridBot}" stroke="${th.gridYear}"/>`,
);
parts.push(
`<text x="${sx(ts)}" y="${gridBot + 18}" font-size="11" font-weight="600" fill="${th.muted}" text-anchor="middle">${lbl}</text>`,
);
}
const axisC = th.flat ? "#000" : th.gridYear;
parts.push(
`<line x1="${th.flat ? chartX : chartX - 4}" y1="${gridBot}" x2="${chartX + chartW + 4}" y2="${gridBot}" stroke="${axisC}"/>`,
);
if (th.flat) {
parts.push(
`<line x1="${chartX}" y1="${gridTop}" x2="${chartX}" y2="${gridBot}" stroke="#000"/>`,
);
for (const [ts] of ticks.major)
parts.push(
`<line x1="${sx(ts)}" y1="${gridBot}" x2="${sx(ts)}" y2="${gridBot + 5}" stroke="${th.muted}"/>`,
);
for (const ts of ticks.minor)
parts.push(
`<line x1="${sx(ts)}" y1="${gridBot}" x2="${sx(ts)}" y2="${gridBot + 3}" stroke="${th.muted}"/>`,
);
}
const defs = [];
rows.forEach((c, i) => {
const y = rowLayout[i].y,
cy = y + ROW_H / 2;
if (c.avatar)
defs.push(
`<clipPath id="av${i}"><circle cx="${labelW - 16 - AVATAR / 2}" cy="${cy}" r="${AVATAR / 2}"/></clipPath>`,
);
if (isExpanded(c))
defs.push(
`<clipPath id="exp${i}"><rect x="${chartX}" y="${y}" width="${chartW}" height="${ROW_H + EXP_H}"/></clipPath>`,
);
});
parts.push(`<defs>${defs.join("")}</defs>`);
if (th.flat)
rows.forEach((c, i) => {
if (isExpanded(c)) return;
const by = rowLayout[i].y + (ROW_H - barH) / 2;
parts.push(
`<rect x="${chartX}" y="${by}" width="${chartW}" height="${barH}" fill="${th.track}"/>`,
);
});
const relOpacity = th.flat ? 1 : 0.4;
if (showReleases)
for (const rel of DATA.repo.releases) {
if (rel.ts < t0 || rel.ts > t1) continue;
const x = sx(rel.ts).toFixed(1);
parts.push(
`<line x1="${x}" y1="${gridTop}" x2="${x}" y2="${gridBot}" stroke="${th.release}" opacity="${relOpacity}"><title>${esc(rel.name)}</title></line>`,
);
}
rows.forEach((c, i) => {
const y = rowLayout[i].y,
rh = rowLayout[i].h,
cy = y + ROW_H / 2;
const expanded = isExpanded(c);
const color = rowColor(c);
const first = Math.max(c.first, t0),
last = Math.min(c.last, t1);
const bx = sx(first),
bw = Math.max(sx(last) - bx, 3);
const by = y + (ROW_H - barH) / 2;
const r = [`<g class="crow${expanded ? " on" : ""}" data-i="${i}">`];
r.push(
`<rect class="row-hit" x="0" y="${y}" width="${width}" height="${rh}" rx="${th.flat ? 0 : 6}"/>`,
);
const chx = 7,
chev = expanded
? `M${chx} ${cy - 1.5}l3 3.5 3-3.5`
: `M${chx + 1} ${cy - 3}l3.5 3-3.5 3`;
r.push(
`<path class="chev" d="${chev}" fill="none" stroke="${th.faint}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>`,
);
const acx = labelW - 16 - AVATAR / 2;
if (c.isGroup) {
const label = c.members < 100 ? String(c.members) : "99+";
r.push(
`<circle cx="${acx}" cy="${cy}" r="${AVATAR / 2}" fill="${color}"/>`,
);
r.push(
`<text x="${acx}" y="${cy + 3}" font-size="${c.members < 100 ? 9 : 7}" font-weight="700" fill="#fff" text-anchor="middle">${label}</text>`,
);
} else if (c.avatar) {
r.push(
`<image x="${acx - AVATAR / 2}" y="${cy - AVATAR / 2}" width="${AVATAR}" height="${AVATAR}" preserveAspectRatio="xMidYMid slice" clip-path="url(#av${i})" href="${esc(c.avatar)}"/>`,
);
} else {
const hue = hashHue(c.name);
r.push(
`<circle cx="${acx}" cy="${cy}" r="${AVATAR / 2}" fill="hsl(${hue},42%,${themeName() === "dark" ? 45 : 62}%)"/>`,
);
r.push(
`<text x="${acx}" y="${cy + 3}" font-size="8" font-weight="700" fill="#fff" text-anchor="middle">${esc(initials(c.name))}</text>`,
);
}
let display = c.name;
while (
textWidth(display, nameSize, 500) > labelW - 52 &&
display.length > 4
)
display = display.slice(0, -2) + "…";
const nameEl = `<text x="${labelW - 16 - AVATAR - 8}" y="${cy + 4.2}" font-size="${nameSize}" font-weight="500" fill="${th.text}" text-anchor="end">${esc(display)}${c.bot ? ` <tspan fill="${th.faint}" font-size="10">bot</tspan>` : ""}</text>`;
r.push(
c.url
? `<a href="${esc(c.url)}" target="_blank" rel="noopener">${nameEl}</a>`
: nameEl,
);
if (!expanded) {
if (th.flat) {
rowBars(c).forEach((bar) => {
const x0 = sx(Math.max(monthStartTs(c.m0 + bar.a), t0));
const x1 = sx(Math.min(monthStartTs(c.m0 + bar.b + 1), t1));
const w = Math.max(x1 - x0, 6);
const x = x1 - x0 < 6 ? x0 + (x1 - x0) / 2 - 3 : x0;
r.push(
`<rect x="${x.toFixed(1)}" y="${by}" width="${w.toFixed(1)}" height="${barH}" fill="${bar.color}"/>`,
);
});
} else {
const sm =
state.color === "solid" ? null : smoothMonths(c.months);
const smax = sm ? maxOf(sm, 1e-9) : 1;
rowBars(c).forEach((bar, bi) => {
const x0 = sx(Math.max(monthStartTs(c.m0 + bar.a), t0));
const x1 = sx(Math.min(monthStartTs(c.m0 + bar.b + 1), t1));
const w = Math.max(x1 - x0, 3);
const rx = Math.min(barH / 2, w / 2);
r.push(
`<rect x="${x0.toFixed(1)}" y="${by}" width="${w.toFixed(1)}" height="${barH}" rx="${rx}" fill="${bar.color}" opacity="0.16"/>`,
);
if (state.color === "solid") {
r.push(
`<rect x="${x0.toFixed(1)}" y="${by}" width="${w.toFixed(1)}" height="${barH}" rx="${rx}" fill="${bar.color}" opacity="0.85"/>`,
);
} else if (w > 6) {
const cid = `b${i}_${bi}`;
r.push(
`<clipPath id="${cid}"><rect x="${x0.toFixed(1)}" y="${by}" width="${w.toFixed(1)}" height="${barH}" rx="${rx}"/></clipPath>`,
);
const seg = [`<g clip-path="url(#${cid})">`];
for (let mi = bar.a; mi <= bar.b; mi++) {
const sval = sm[mi];
if (sval <= 0) continue;
const m = c.m0 + mi;
const mx0 = sx(Math.max(monthStartTs(m), t0)),
mx1 = sx(Math.min(monthStartTs(m + 1), t1));
if (mx1 < chartX || mx0 > chartX + chartW) continue;
const op = 0.28 + 0.72 * Math.sqrt(sval / smax);
seg.push(
`<rect x="${mx0.toFixed(1)}" y="${by}" width="${Math.max(mx1 - mx0, 1.2).toFixed(1)}" height="${barH}" fill="${bar.color}" opacity="${op.toFixed(2)}"/>`,
);
}
seg.push("</g>");
r.push(seg.join(""));
} else {
r.push(
`<circle cx="${(x0 + w / 2).toFixed(1)}" cy="${cy}" r="${barH / 2 - 1}" fill="${bar.color}" opacity="0.9"/>`,
);
}
});
}
}
r.push(
`<text x="${width - marginR}" y="${cy + 3.8}" font-size="10.5" fill="${th.faint}" text-anchor="end">${fmtNum(shownCommits(c))}</text>`,
);
if (expanded) {
const months = c.months,
n = months.length;
const active = months.filter(Boolean).length;
const spanMonths = Math.max(
1,
Math.round((c.last - c.first) / 2629800) + 1,
);
const span =
spanMonths >= 24
? (spanMonths / 12).toFixed(1) + " years"
: spanMonths + " month" + (spanMonths === 1 ? "" : "s");
const ix = labelW - 16 - AVATAR - 8; let iy = y + ROW_H + 8;
const info = (txt, fill, o) => {
o = o || {};
r.push(
`<text x="${ix}" y="${iy}" font-size="${o.size || 12.5}" font-weight="${o.weight || 400}" fill="${fill}" text-anchor="end">${txt}</text>`,
);
iy += o.gap || 17;
};
const fitLeft = (str, size) => {
let s = str;
while (s.length > 1 && textWidth(s, size, 400) > ix - 6)
s = s.slice(0, -2) + "…";
return s;
};
if (c.isGroup) {
info(
`${fmtNum(c.members)} ${c.members === 1 ? "contributor" : "contributors"}`,
th.muted,
);
const names = c.member_names || [];
const extra = c.members - names.length;
let list = names.join(", ") + (extra > 0 ? ` +${extra}` : "");
if (list)
info(esc(fitLeft(list, 11.5)), th.muted, { size: 11.5 });
} else {
if (c.login) info("@" + esc(c.login), th.muted);
const segs = affiliationSegments(c);
if (segs.length > 1) {
for (const s of segs) {
const col = gcol(s.group);
const yr = segYears(s);
const yrW = textWidth(yr, 11, 400) + 8;
let g = s.group;
while (g.length > 1 && textWidth(g, 12.5, 600) > ix - yrW - 6)
g = g.slice(0, -2) + "…";
r.push(
`<text x="${ix}" y="${iy}" font-size="12.5" font-weight="600" fill="${col}" text-anchor="end">${esc(g)} <tspan fill="${th.faint}" font-weight="400">${yr}</tspan></text>`,
);
iy += 17;
}
} else if (c.group) {
info(esc(fitLeft(c.group, 12.5)), gcol(c.group), {
weight: 600,
});
}
}
info(`${fmtNum(c.commits)} commits`, th.text, { weight: 600 });
info(`${fmtDate(c.first)} – ${fmtDate(c.last)}`, th.muted, {
size: 11.5,
});
info(
`${active} active month${active === 1 ? "" : "s"} · ${span}`,
th.muted,
{
size: 11.5,
},
);
const yTop = y + 12,
yBot = y + rh - 12,
ph = yBot - yTop;
const gmax = globalMonthlyMax();
r.push(
`<line x1="${chartX}" y1="${yBot}" x2="${chartX + chartW}" y2="${yBot}" stroke="${th.gridMonth}"/>`,
);
const pts = [];
const xs = [];
for (let mi = 0; mi < n; mi++) {
const px = sx(monthStartTs(c.m0 + mi));
const py = yBot - Math.min(1, months[mi] / gmax) * ph;
xs.push(px);
pts.push(`${px.toFixed(1)},${py.toFixed(1)}`);
}
if (n >= 2) {
const x0 = sx(monthStartTs(c.m0)),
x1 = sx(monthStartTs(c.m0 + n - 1));
const filt = state.gsel.size > 0 && !!c.month_groups;
const inSel = (mi) =>
!filt ||
(c.month_groups[mi] && state.gsel.has(c.month_groups[mi]));
r.push(`<g clip-path="url(#exp${i})">`);
if (c.month_groups) {
for (let mi = 1; mi < n; mi++)
if (inSel(mi) && inSel(mi - 1))
r.push(
`<polygon points="${xs[mi - 1].toFixed(1)},${yBot} ${pts[mi - 1]} ${pts[mi]} ${xs[mi].toFixed(1)},${yBot}" fill="${monthColor(c, mi, color)}" fill-opacity="0.18" stroke="none"/>`,
);
for (let mi = 1; mi < n; mi++)
if (inSel(mi) && inSel(mi - 1))
r.push(
`<polyline points="${pts[mi - 1]} ${pts[mi]}" fill="none" stroke="${monthColor(c, mi, color)}" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/>`,
);
} else {
r.push(
`<polyline points="${x0.toFixed(1)},${yBot} ${pts.join(" ")} ${x1.toFixed(1)},${yBot}" fill="${color}" fill-opacity="0.18" stroke="none"/>`,
);
r.push(
`<polyline points="${pts.join(" ")}" fill="none" stroke="${color}" stroke-width="1.6" stroke-linejoin="round"/>`,
);
}
r.push("</g>");
} else if (n === 1) {
const [px, py] = pts[0].split(",");
r.push(`<circle cx="${px}" cy="${py}" r="3" fill="${color}"/>`);
}
}
r.push("</g>");
parts.push(r.join(""));
});
if (showReleases) {
const label = "Vertical lines mark releases";
const tw = textWidth(label, 10.5, 400);
const start = chartX + (chartW - (10 + tw)) / 2;
const capY = height - 7;
parts.push(
`<line x1="${start}" y1="${capY - 7}" x2="${start}" y2="${capY + 1}" stroke="${th.release}" stroke-width="1" opacity="${relOpacity}"/>`,
);
parts.push(
`<text x="${start + 10}" y="${capY}" font-size="10.5" fill="${th.muted}">${label}</text>`,
);
}
svg.innerHTML = parts.join("");
updateShowing(rows, eligible, total);
updateExpandBtn();
}
function updateExpandBtn() {
const anyVis = currentRows.some(isExpanded);
$("#expand-all").textContent = anyVis ? "Collapse all" : "Expand all";
}
$("#expand-all").addEventListener("click", () => {
if (currentRows.some(isExpanded)) state.expanded.clear();
else currentRows.forEach((c) => state.expanded.add(rowKey(c)));
hideTooltip();
renderChart();
});
function updateShowing(rows, eligible, total) {
const noun = affMode() ? "affiliations" : "contributors";
let txt = `<b>${fmtNum(rows.length)}</b> of <b>${fmtNum(total)}</b> ${noun}`;
if (rows.length < eligible)
txt += ` (top ${fmtNum(rows.length)} by commits)`;
$("#showing").innerHTML = txt;
}
const tooltip = $("#tooltip");
let ttRow = -1;
$("#chart").addEventListener("pointermove", (e) => {
const svg = $("#chart");
const rect = svg.getBoundingClientRect();
const y = e.clientY - rect.top;
const i = rowAtY(y);
if (i < 0 || i >= currentRows.length) {
hideTooltip();
return;
}
const c = currentRows[i];
if (isExpanded(c)) {
hideTooltip();
return;
}
if (i !== ttRow) {
ttRow = i;
tooltip.innerHTML = tooltipHtml(c);
}
tooltip.classList.add("show");
const tw = tooltip.offsetWidth,
thh = tooltip.offsetHeight;
let x = e.clientX + 16,
ty = e.clientY + 14;
if (x + tw > innerWidth - 12) x = e.clientX - tw - 14;
if (ty + thh > innerHeight - 12) ty = e.clientY - thh - 12;
tooltip.style.left = x + "px";
tooltip.style.top = ty + "px";
});
$("#chart").addEventListener("pointerleave", hideTooltip);
function hideTooltip() {
tooltip.classList.remove("show");
ttRow = -1;
}
$("#chart").addEventListener("click", (e) => {
if (e.target.closest("a")) return; const rect = $("#chart").getBoundingClientRect();
const i = rowAtY(e.clientY - rect.top);
if (i < 0 || i >= currentRows.length) return;
const key = rowKey(currentRows[i]);
if (state.expanded.has(key)) state.expanded.delete(key);
else state.expanded.add(key);
hideTooltip();
renderChart();
});
function tooltipHtml(c) {
const months = Math.max(
1,
Math.round((c.last - c.first) / 2629800) + 1,
);
const active = c.months.filter(Boolean).length;
const span =
months >= 24
? (months / 12).toFixed(1) + " years"
: months + " months";
const color = rowColor(c);
const spark = sparkline(c, color);
if (c.isGroup) {
const names = c.member_names || [];
const extra = c.members - names.length;
const people =
names.map(esc).join(", ") + (extra > 0 ? ` +${extra} more` : "");
return `<div class="tt-head"><span class="ph" style="background:${color}">${c.members < 100 ? c.members : "99+"}</span>
<div><div class="tt-name">${esc(c.name)}</div><div class="tt-login">${fmtNum(c.members)} ${c.members === 1 ? "contributor" : "contributors"}</div></div></div>
<dl class="tt-grid">
<dt>Commits</dt><dd>${fmtNum(c.commits)}</dd>
<dt>First commit</dt><dd>${fmtDate(c.first)}</dd>
<dt>Latest commit</dt><dd>${fmtDate(c.last)}</dd>
<dt>Active span</dt><dd>${span}</dd>
</dl>
${people ? `<div class="tt-members">${people}</div>` : ""}
<div class="tt-spark">${spark}</div>`;
}
const av = c.avatar
? `<img src="${esc(c.avatar)}" alt="">`
: `<span class="ph" style="background:hsl(${hashHue(c.name)},42%,55%)">${esc(initials(c.name))}</span>`;
const segs = affiliationSegments(c);
const badge =
!ANY_MULTI_AFFIL && c.group
? `<span class="badge" style="background:${gcol(c.group)}22;color:${gcol(c.group)}">${esc(c.group)}</span>`
: "";
const affHtml =
ANY_MULTI_AFFIL && segs.length
? `<div class="tt-affil">${segs
.map(
(s) =>
`<span class="tt-affil-row"><span class="dot" style="background:${gcol(s.group)}"></span><span style="color:${gcol(s.group)};font-weight:600">${esc(s.group)}</span> <span style="opacity:.6">${segYears(s)}</span></span>`,
)
.join("")}</div>`
: "";
return `<div class="tt-head">${av}<div><div class="tt-name">${esc(c.name)}${badge}</div>${c.login ? `<div class="tt-login">@${esc(c.login)}</div>` : ""}</div></div>
${affHtml}
<dl class="tt-grid">
<dt>Commits</dt><dd>${fmtNum(c.commits)}</dd>
<dt>First commit</dt><dd>${fmtDate(c.first)}</dd>
<dt>Latest commit</dt><dd>${fmtDate(c.last)}</dd>
<dt>Active span</dt><dd>${span}</dd>
<dt>Active months</dt><dd>${active}</dd>
</dl>
<div class="tt-spark">${spark}</div>`;
}
function sparkline(c, color) {
const w = 250,
h = 34;
const n = c.months.length;
if (n < 2) return "";
const max = maxOf(c.months, 1);
const baseY = h - 2;
const xs = c.months.map((v, i) => (i / (n - 1)) * w);
const pts = c.months.map(
(v, i) =>
`${xs[i].toFixed(1)},${(h - 2 - (v / max) * (h - 6)).toFixed(1)}`,
);
let body;
if (c.month_groups) {
body = "";
for (let i = 1; i < n; i++)
body += `<polygon points="${xs[i - 1].toFixed(1)},${baseY} ${pts[i - 1]} ${pts[i]} ${xs[i].toFixed(1)},${baseY}" fill="${monthColor(c, i, color)}" fill-opacity="0.16" stroke="none"/>`;
for (let i = 1; i < n; i++)
body += `<polyline points="${pts[i - 1]} ${pts[i]}" fill="none" stroke="${monthColor(c, i, color)}" stroke-width="1.4" stroke-linejoin="round"/>`;
} else {
body = `<polyline points="0,${baseY} ${pts.join(" ")} ${w},${baseY}" fill="${color}22" stroke="none"/><polyline points="${pts.join(" ")}" fill="none" stroke="${color}" stroke-width="1.4" stroke-linejoin="round"/>`;
}
return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
}
const CTX_H = 64;
let ctxGeom = null;
function repoMonthlySeries() {
const m0 = monthIndex(REPO.first),
m1 = monthIndex(REPO.last);
const bins = new Array(m1 - m0 + 1).fill(0);
for (const c of HUMANS) {
c.months.forEach((v, i) => {
const idx = c.m0 + i - m0;
if (idx >= 0 && idx < bins.length) bins[idx] += v;
});
}
return { m0, bins };
}
const SERIES = repoMonthlySeries();
function renderContext() {
const svg = $("#context");
const th = theme();
const { width, chartX, chartW } = computeLayout();
svg.setAttribute("width", width);
svg.setAttribute("viewBox", `0 0 ${width} ${CTX_H}`);
const padX = 6;
const x0 = state.align ? chartX : padX;
const w = state.align ? chartW : width - padX * 2;
const d0 = REPO.first,
d1 = REPO.last;
const [t0, t1] = paddedDomain(d0, d1);
const sx = (ts) => x0 + ((ts - t0) / (t1 - t0)) * w;
ctxGeom = { sx, t0, t1, x0, w };
const { m0, bins } = SERIES;
const max = maxOf(bins, 1);
const baseY = CTX_H - 16;
const pts = bins.map((v, i) => {
const ts = (monthStartTs(m0 + i) + monthStartTs(m0 + i + 1)) / 2;
return `${sx(Math.min(Math.max(ts, d0), d1)).toFixed(1)},${(baseY - Math.sqrt(v / max) * (baseY - 6)).toFixed(1)}`;
});
const parts = [];
for (const [ts, lbl] of timeTicks(t0, t1).major) {
parts.push(
`<line x1="${sx(ts)}" y1="2" x2="${sx(ts)}" y2="${baseY}" stroke="${th.gridYear}"/>`,
);
parts.push(
`<text x="${sx(ts)}" y="${CTX_H - 4}" font-size="9.5" fill="${th.faint}" text-anchor="middle">${lbl}</text>`,
);
}
parts.push(
`<polyline points="${sx(d0)},${baseY} ${pts.join(" ")} ${sx(d1)},${baseY}" fill="${th.ctxArea}" stroke="none"/>`,
);
parts.push(
`<polyline points="${pts.join(" ")}" fill="none" stroke="${th.ctxLine}" stroke-width="1.2"/>`,
);
parts.push(
`<line x1="${x0}" y1="${baseY}" x2="${x0 + w}" y2="${baseY}" stroke="${th.gridYear}"/>`,
);
parts.push(`<g id="brushg"></g>`);
svg.innerHTML = parts.join("");
updateBrushVisual();
}
function updateBrushVisual() {
const g = $("#brushg");
if (!g || !ctxGeom) return;
const th = theme();
const baseY = CTX_H - 16;
if (state.t0 == null) {
g.innerHTML = "";
$("#brush-label").textContent = "";
return;
}
const x0 = ctxGeom.sx(state.t0),
x1 = ctxGeom.sx(state.t1);
const gx0 = ctxGeom.x0,
gx1 = ctxGeom.x0 + ctxGeom.w;
g.innerHTML =
`<rect x="${gx0}" y="2" width="${Math.max(0, x0 - gx0)}" height="${baseY - 2}" fill="${theme().dim}"/>` +
`<rect x="${x1}" y="2" width="${Math.max(0, gx1 - x1)}" height="${baseY - 2}" fill="${theme().dim}"/>` +
`<rect class="sel" x="${x0}" y="2" width="${Math.max(2, x1 - x0)}" height="${baseY - 2}" fill="none" stroke="${ACCENT}" stroke-width="1.3" rx="3" style="cursor:grab"/>` +
`<rect class="hl" x="${x0 - 3.5}" y="${baseY / 2 - 9}" width="7" height="22" rx="3.5" fill="${ACCENT}" style="cursor:ew-resize"/>` +
`<rect class="hr" x="${x1 - 3.5}" y="${baseY / 2 - 9}" width="7" height="22" rx="3.5" fill="${ACCENT}" style="cursor:ew-resize"/>`;
$("#brush-label").textContent =
fmtMonthYear(state.t0) + " – " + fmtMonthYear(state.t1);
}
(function initBrush() {
const svg = $("#context");
let drag = null;
const tsAt = (clientX) => {
const rect = svg.getBoundingClientRect();
const x = Math.min(
Math.max(clientX - rect.left, ctxGeom.x0),
ctxGeom.x0 + ctxGeom.w,
);
return (
ctxGeom.t0 +
((x - ctxGeom.x0) / ctxGeom.w) * (ctxGeom.t1 - ctxGeom.t0)
);
};
svg.addEventListener("pointerdown", (e) => {
if (!ctxGeom) return;
svg.setPointerCapture(e.pointerId);
const ts = tsAt(e.clientX);
const cls = e.target.getAttribute && e.target.getAttribute("class");
if (cls === "hl") drag = { mode: "resize", fix: state.t1 };
else if (cls === "hr") drag = { mode: "resize", fix: state.t0 };
else if (cls === "sel")
drag = { mode: "move", start: ts, t0: state.t0, t1: state.t1 };
else {
drag = { mode: "new", anchor: ts };
state.t0 = ts;
state.t1 = ts;
}
e.preventDefault();
});
svg.addEventListener("pointermove", (e) => {
if (!drag) return;
const ts = tsAt(e.clientX);
if (drag.mode === "new" || drag.mode === "resize") {
const fix = drag.mode === "new" ? drag.anchor : drag.fix;
state.t0 = Math.min(fix, ts);
state.t1 = Math.max(fix, ts);
} else {
const dt = ts - drag.start;
const span = drag.t1 - drag.t0;
let nt0 = drag.t0 + dt;
nt0 = Math.min(Math.max(nt0, ctxGeom.t0), ctxGeom.t1 - span);
state.t0 = nt0;
state.t1 = nt0 + span;
}
updateBrushVisual();
scheduleRender();
});
const finish = () => {
if (!drag) return;
if (state.t0 != null && state.t1 - state.t0 < 86400 * 3) {
state.t0 = state.t1 = null;
}
drag = null;
updateBrushVisual();
scheduleRender();
writeHash();
};
svg.addEventListener("pointerup", finish);
svg.addEventListener("pointercancel", finish);
svg.addEventListener("dblclick", () => {
state.t0 = state.t1 = null;
updateBrushVisual();
scheduleRender();
writeHash();
});
})();
let renderQueued = false;
function scheduleRender() {
if (renderQueued) return;
renderQueued = true;
requestAnimationFrame(() => {
renderQueued = false;
renderChart();
renderLegend();
});
}
function legendCounts() {
const q = state.q.trim().toLowerCase();
const [t0, t1] = domain();
const m = new Map();
const bump = (g) => g && m.set(g, (m.get(g) || 0) + 1);
for (const c of HUMANS) {
if (!rowMatches(c, q, t0, t1)) continue;
bump(c.group);
if (c.month_groups) {
const seen = new Set();
for (const g of c.month_groups)
if (g && g !== c.group && !seen.has(g)) {
seen.add(g);
bump(g);
}
}
}
return [...m.entries()].sort((a, b) => b[1] - a[1]);
}
function renderLegend() {
const el = $("#legend");
if (!GROUPS.length || state.color !== "group" || affMode()) {
el.hidden = true;
return;
}
const counts = legendCounts();
if (!counts.length) {
el.hidden = true;
return;
}
el.hidden = false;
el.classList.toggle("has-sel", state.gsel.size > 0);
const TOPN = 18;
const collapsed = !state.legendExpanded && counts.length > TOPN;
const shown = collapsed ? counts.slice(0, TOPN) : counts;
let html = shown
.map(
([g, n]) =>
`<span class="chip${state.gsel.has(g) ? " on" : ""}" data-g="${esc(g)}" role="button" tabindex="0" title="Click to filter">` +
`<span class="dot" style="background:${gcol(g)}"></span>${esc(g)} <span class="n">${n}</span></span>`,
)
.join("");
if (counts.length > TOPN) {
html += `<span class="chip more" id="legend-more" role="button" tabindex="0">${
collapsed ? "+" + (counts.length - TOPN) + " more" : "Show fewer"
}</span>`;
}
el.innerHTML = html;
el.querySelectorAll(".chip[data-g]").forEach((chip) => {
chip.addEventListener("click", () => {
const g = chip.dataset.g;
if (state.gsel.has(g)) state.gsel.delete(g);
else state.gsel.add(g);
renderLegend();
scheduleRender();
writeHash();
});
});
const more = $("#legend-more");
if (more)
more.addEventListener("click", () => {
state.legendExpanded = !state.legendExpanded;
renderLegend();
});
}
function renderHeader() {
const icon = `<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M5 3.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm0 2.122a2.25 2.25 0 1 0-1.5 0v.878A2.25 2.25 0 0 0 5.75 8.5h1.5v2.128a2.251 2.251 0 1 0 1.5 0V8.5h1.5a2.25 2.25 0 0 0 2.25-2.25v-.878a2.25 2.25 0 1 0-1.5 0v.878a.75.75 0 0 1-.75.75h-4.5A.75.75 0 0 1 5 6.25v-.878Zm3.75 7.378a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm3-8.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"/></svg>`;
const name = esc(REPO.name);
const mark = REPO.owner_avatar
? `<img class="owner" src="${REPO.owner_avatar}" alt="">`
: icon;
$("#repo-title").innerHTML =
mark +
(REPO.url
? `<a href="${esc(REPO.url)}" target="_blank" rel="noopener">${name}</a>`
: name);
$("#mn-title").textContent = REPO.name;
$("#stats").innerHTML =
`<span><b>${fmtNum(REPO.total_contributors)}</b> contributors</span>` +
`<span><b>${fmtNum(REPO.total_commits)}</b> commits</span>` +
`<span>${fmtMonthYear(REPO.first)} – ${fmtMonthYear(REPO.last)}</span>` +
`<span>branch <b>${esc(REPO.branch)}</b></span>`;
const desc = $("#repo-desc");
if (REPO.description) {
desc.textContent = REPO.description;
desc.hidden = false;
} else {
desc.hidden = true;
}
$("#footer").innerHTML =
`Generated ${esc(REPO.generated)} · made with <a href="https://github.com/ewels/contributor-graphs" target="_blank" rel="noopener">contributor-graphs</a>`;
document.title = REPO.name + " · contributors";
}
function exportSvgString() {
const SVGNS = "http://www.w3.org/2000/svg";
const src = $("#chart");
const svg = src.cloneNode(true);
const w = parseFloat(src.getAttribute("width"));
const h = parseFloat(src.getAttribute("height"));
const footH = 22;
svg.setAttribute("height", h + footH);
svg.setAttribute("viewBox", `0 0 ${w} ${h + footH}`);
const bg = document.createElementNS(SVGNS, "rect");
bg.setAttribute("width", "100%");
bg.setAttribute("height", "100%");
bg.setAttribute("fill", theme().card);
svg.insertBefore(bg, svg.firstChild);
const foot = document.createElementNS(SVGNS, "text");
foot.setAttribute("x", w - 12);
foot.setAttribute("y", h + 15);
foot.setAttribute("text-anchor", "end");
foot.setAttribute("font-size", "10");
foot.setAttribute("fill", theme().faint);
foot.textContent = "Generated by ewels/contributor-graphs";
svg.appendChild(foot);
svg.setAttribute("xmlns", SVGNS);
svg.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
return (
'<?xml version="1.0" encoding="UTF-8"?>\n' +
new XMLSerializer().serializeToString(svg)
);
}
function download(name, blob) {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
}
const fileBase =
(REPO.slug || REPO.name).replace(/[^A-Za-z0-9.-]+/g, "-") +
"-contributors";
$("#dl-svg").addEventListener("click", () => {
download(
fileBase + ".svg",
new Blob([exportSvgString()], { type: "image/svg+xml" }),
);
});
$("#dl-png").addEventListener("click", () => {
const src = exportSvgString();
const img = new Image();
const scale = 2;
img.onload = () => {
const canvas = document.createElement("canvas");
const w = $("#chart").getAttribute("width"),
h = $("#chart").getAttribute("height");
canvas.width = w * scale;
canvas.height = h * scale;
const ctx = canvas.getContext("2d");
ctx.scale(scale, scale);
ctx.drawImage(img, 0, 0);
canvas.toBlob(
(b) => b && download(fileBase + ".png", b),
"image/png",
);
};
img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(src);
});
function totalUnits() {
const base = ALL.filter((c) => state.bots || !c.bot);
if (affMode()) return aggregateByGroup(base).length;
return base.filter(hasCommits).length;
}
function syncControls() {
const aff = affMode();
$("#rows").value = state.mode;
$("#search").value = state.q;
$("#search").placeholder = aff ? "Affiliation…" : "Name or username…";
minRange.value = minToSlider(state.min);
$("#min-num").value = state.min;
$("#min-num").max = MAX_COMMITS;
$("#topn").value = state.top;
$("#topn").max = totalUnits();
$("#topn-of").textContent = "of " + fmtNum(totalUnits());
$("#sort").value = state.sort;
$("#color").value = state.color;
$("#color").disabled = aff;
$("#color").closest(".ctl").style.opacity = aff ? ".5" : "";
$("#bots").checked = state.bots;
$("#coauthors").checked = state.coauthors;
$("#releases").checked = state.releases;
$("#align-ctx").setAttribute(
"aria-pressed",
state.align ? "true" : "false",
);
$("#wide-toggle").setAttribute(
"aria-pressed",
state.wide ? "true" : "false",
);
document.querySelector(".wrap").classList.toggle("wide", state.wide);
}
$("#align-ctx").addEventListener("click", () => {
state.align = !state.align;
$("#align-ctx").setAttribute(
"aria-pressed",
state.align ? "true" : "false",
);
renderContext();
writeHash();
});
$("#wide-toggle").addEventListener("click", () => {
state.wide = !state.wide;
document.querySelector(".wrap").classList.toggle("wide", state.wide);
$("#wide-toggle").setAttribute(
"aria-pressed",
state.wide ? "true" : "false",
);
renderAll();
writeHash();
});
function setMenu(open) {
document.body.classList.toggle("menu-open", open);
$("#burger").setAttribute("aria-expanded", open ? "true" : "false");
}
function syncNav() {
const h = $("#mobile-nav").offsetHeight || 0;
document.documentElement.style.setProperty("--nav-h", h + "px");
if (matchMedia("(min-width: 761px)").matches) setMenu(false);
}
$("#burger").addEventListener("click", () =>
setMenu(!document.body.classList.contains("menu-open")),
);
$("#menu-backdrop").addEventListener("click", () => setMenu(false));
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") setMenu(false);
});
addEventListener("resize", syncNav);
$("#rows").addEventListener("change", (e) => {
state.mode = e.target.value;
state.gsel.clear();
state.expanded.clear(); syncControls();
renderLegend();
renderContext(); scheduleRender();
writeHash();
});
$("#search").addEventListener("input", (e) => {
state.q = e.target.value;
scheduleRender();
writeHash();
});
minRange.addEventListener("input", (e) => {
state.min = sliderToMin(+e.target.value);
$("#min-num").value = state.min;
scheduleRender();
writeHash();
});
$("#min-num").addEventListener("input", (e) => {
state.min = Math.max(1, +e.target.value || 1);
minRange.value = minToSlider(state.min);
scheduleRender();
writeHash();
});
$("#topn").addEventListener("input", (e) => {
const v = Math.max(
1,
Math.min(totalUnits(), +e.target.value || DEFAULT_TOP),
);
state.top = v;
scheduleRender();
writeHash();
});
$("#sort").addEventListener("change", (e) => {
state.sort = e.target.value;
scheduleRender();
writeHash();
});
$("#color").addEventListener("change", (e) => {
state.color = e.target.value;
renderLegend();
scheduleRender();
writeHash();
});
$("#bots").addEventListener("change", (e) => {
state.bots = e.target.checked;
syncControls();
renderContext(); scheduleRender();
writeHash();
});
$("#coauthors").addEventListener("change", (e) => {
state.coauthors = e.target.checked;
applyCoauthors();
syncControls();
renderAll();
writeHash();
});
$("#releases").addEventListener("change", (e) => {
state.releases = e.target.checked;
renderChart();
});
$("#reset").addEventListener("click", () => {
state.mode = DEFAULT_MODE;
state.q = "";
state.min = 1;
state.top = DEFAULT_TOP;
state.sort = "first";
state.color = DEFAULT_COLOR;
state.bots = false;
state.coauthors = true;
applyCoauthors();
state.releases = !!theme().flat && HAS_RELEASES;
state.align = false;
state.wide = false;
state.t0 = state.t1 = null;
state.gsel.clear();
state.expanded.clear();
syncControls();
updateBrushVisual();
renderAll();
writeHash();
});
$("#theme").addEventListener("change", (e) => setTheme(e.target.value));
let resizeTimer;
new ResizeObserver(() => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(renderAll, 120);
}).observe(document.querySelector(".chart-card"));
function renderAll() {
renderLegend();
renderContext();
renderChart();
}
readHash();
applyCoauthors();
buildThemeMenu();
const locked = !!DATA.lockTheme;
const initialTheme = locked
? DATA.defaultTheme || "light"
: localStorage.getItem("cg-theme") ||
(THEMES[DATA.defaultTheme] && DATA.defaultTheme) ||
(matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light");
document.documentElement.dataset.theme = THEMES[initialTheme]
? initialTheme
: "light";
if (theme().flat && HAS_RELEASES) state.releases = true;
if (locked) {
$("#theme").hidden = true;
} else {
$("#theme").value = themeName();
}
renderHeader();
syncControls();
renderAll();
syncNav();
</script>
</body>
</html>