mirror of
https://github.com/tiennm99/awesome-ai-dev-tools.git
synced 2026-10-11 03:13:05 +00:00
Hovering a date now lists all plotted repos ordered by stars at that point, with exact counts, so the chart answers "who was ahead when" instead of only showing one value at a time. The series under the cursor is bold in the list, stays solid and thickens, and every other line goes thin and dashed — readable with forty lines on screen. Chart.js paints its tooltip on the canvas and cannot weight one row differently, so the readout is an HTML card positioned beside the caret (flipped and clamped to stay inside the chart) and the built-in tooltip is off. Forty rows fit as three columns; names truncate on a shared grid so the counts align. Hidden series are excluded, so the ranking always matches what is drawn.
618 lines
31 KiB
HTML
618 lines
31 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Awesome Coding Agents — Rankings & History</title>
|
|
<meta name="description" content="Daily-refreshed ranking of open-source AI coding agents by GitHub stars, with 7-day deltas and star-history charts.">
|
|
<meta property="og:title" content="Awesome Coding Agents — Rankings & History">
|
|
<meta property="og:description" content="Daily-refreshed ranking of open-source AI coding agents by GitHub stars, with 7-day deltas and star-history charts.">
|
|
<meta property="og:url" content="https://tiennm99.github.io/awesome-coding-agents/">
|
|
<meta property="og:type" content="website">
|
|
<meta name="theme-color" content="#f8fafc" media="(prefers-color-scheme: light)">
|
|
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
|
|
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20100%20100%22%3E%3Ctext%20y%3D%22.9em%22%20font-size%3D%2290%22%3E%E2%AD%90%3C%2Ftext%3E%3C%2Fsvg%3E">
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@500;600&family=Fira+Sans:wght@400;500;600&display=swap">
|
|
<style>
|
|
/* Design system: "Data-Dense Dashboard" (ui-ux-pro-max) — blue primary,
|
|
amber accent, slate neutrals, Fira Code (data/headings) + Fira Sans (body). */
|
|
:root {
|
|
--bg: #f8fafc; --surface: #ffffff; --surface-2: #f1f5f9;
|
|
--fg: #1e293b; --muted: #5b6779; --border: #e2e8f0;
|
|
--primary: #2563eb; --primary-fg: #ffffff; --link: #1d4ed8;
|
|
--accent: #b45309; --pos: #15803d; --neg: #b91c1c;
|
|
--row-hover: #f1f5f9; --shadow: 0 1px 2px rgb(15 23 42 / .06);
|
|
color-scheme: light dark;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--bg: #0f172a; --surface: #1e293b; --surface-2: #263449;
|
|
--fg: #e2e8f0; --muted: #a5b1c5; --border: #334155;
|
|
--primary: #3b82f6; --primary-fg: #ffffff; --link: #93c5fd;
|
|
--accent: #fbbf24; --pos: #4ade80; --neg: #f87171;
|
|
--row-hover: #263449; --shadow: 0 1px 2px rgb(0 0 0 / .4);
|
|
}
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0; padding: 1.5rem; background: var(--bg); color: var(--fg);
|
|
font: 16px/1.5 "Fira Sans", -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
max-width: 1200px; margin-inline: auto;
|
|
}
|
|
code, .mono, h1, h2, .stat b, td.num, th.num, a.repo, .cat, .stalebadge, .dir {
|
|
font-family: "Fira Code", ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
}
|
|
header.hero { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.25rem; }
|
|
h1 { font-size: 1.55rem; font-weight: 600; margin: 0 0 .3rem; letter-spacing: -.02em; display: flex; align-items: center; gap: .55rem; }
|
|
h1 svg { color: var(--accent); flex: none; }
|
|
.sub { color: var(--muted); max-width: 46rem; }
|
|
.ghlink {
|
|
display: inline-flex; align-items: center; gap: .45rem; white-space: nowrap;
|
|
border: 1px solid var(--border); background: var(--surface); color: var(--fg);
|
|
border-radius: 8px; padding: .45rem .9rem; text-decoration: none; font-weight: 500;
|
|
box-shadow: var(--shadow); transition: border-color .15s ease, background-color .15s ease;
|
|
}
|
|
.ghlink:hover { border-color: var(--primary); }
|
|
.vh {
|
|
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
|
|
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
|
}
|
|
/* KPI stat cards */
|
|
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(11.5rem, 1fr)); gap: .75rem; margin: 0 0 1.25rem; }
|
|
.stat {
|
|
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
|
padding: .8rem 1rem; box-shadow: var(--shadow); min-width: 0;
|
|
}
|
|
.stat small { display: block; color: var(--muted); font-size: .74rem; text-transform: uppercase; letter-spacing: .06em; margin-bottom: .25rem; }
|
|
.stat b { display: block; font-size: 1.3rem; font-weight: 600; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.stat .statsub { color: var(--muted); font-size: .82rem; }
|
|
.stat .statsub.pos { color: var(--pos); }
|
|
/* Toolbar */
|
|
.toolbar { display: flex; flex-wrap: wrap; gap: .6rem .75rem; align-items: center; margin: 0 0 .6rem; }
|
|
.searchbox { position: relative; flex: 1 1 15rem; max-width: 26rem; }
|
|
.searchbox svg { position: absolute; left: .7rem; top: 50%; translate: 0 -50%; color: var(--muted); pointer-events: none; }
|
|
#q {
|
|
width: 100%; border: 1px solid var(--border); background: var(--surface); color: var(--fg);
|
|
border-radius: 8px; padding: .5rem .75rem .5rem 2.2rem; font: inherit; box-shadow: var(--shadow);
|
|
transition: border-color .15s ease;
|
|
}
|
|
#q::placeholder { color: var(--muted); opacity: 1; }
|
|
#q:hover { border-color: var(--primary); }
|
|
.chips { display: flex; flex-wrap: wrap; gap: .5rem; margin: 0 0 1rem; }
|
|
.chips button, .btn {
|
|
border: 1px solid var(--border); background: var(--surface); color: var(--fg);
|
|
border-radius: 999px; padding: .35rem .9rem; cursor: pointer; font: inherit; font-size: .9rem; font-weight: 500;
|
|
box-shadow: var(--shadow); transition: background-color .15s ease, border-color .15s ease, color .15s ease;
|
|
}
|
|
.btn { border-radius: 8px; }
|
|
.chips button.on, .btn[aria-pressed="true"] { background: var(--primary); color: var(--primary-fg); border-color: var(--primary); }
|
|
.chips button:hover, .btn:hover { border-color: var(--primary); }
|
|
a:focus-visible, button:focus-visible, input:focus-visible, th[data-k]:focus-visible {
|
|
outline: 2px solid var(--primary); outline-offset: 2px;
|
|
}
|
|
/* Table card */
|
|
.tablewrap {
|
|
overflow-x: auto; border: 1px solid var(--border); border-radius: 12px;
|
|
background: var(--surface); box-shadow: var(--shadow);
|
|
}
|
|
table { border-collapse: collapse; width: 100%; min-width: 840px; }
|
|
th, td { padding: .55rem .75rem; text-align: left; border-top: 1px solid var(--border); }
|
|
thead th {
|
|
border-top: none; background: var(--surface-2); user-select: none; white-space: nowrap;
|
|
font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 600;
|
|
}
|
|
thead th[data-k] { cursor: pointer; }
|
|
thead th[data-k]:hover { color: var(--fg); }
|
|
thead th .dir { color: var(--primary); }
|
|
tbody tr { transition: background-color .15s ease; }
|
|
tbody tr:hover { background: var(--row-hover); }
|
|
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; font-size: .92rem; }
|
|
td.rankcell { color: var(--muted); }
|
|
td.desc { color: var(--muted); font-size: .9rem; max-width: 34rem; }
|
|
td.lang { font-size: .92rem; }
|
|
a.repo { color: var(--link); text-decoration: none; font-weight: 500; white-space: nowrap; font-size: .92rem; }
|
|
a.repo:hover { text-decoration: underline; }
|
|
.pos { color: var(--pos); } .neg { color: var(--neg); }
|
|
/* Tag pills: tinted per facet; the text label carries the meaning */
|
|
td.tags { max-width: 20rem; }
|
|
.taglist { display: flex; flex-wrap: wrap; gap: .25rem; }
|
|
.tag {
|
|
display: inline-block; font-size: .72rem; font-weight: 500; padding: .1rem .5rem;
|
|
border: 1px solid var(--border); border-radius: 999px; color: var(--muted); background: var(--surface-2);
|
|
white-space: nowrap;
|
|
}
|
|
.tag-surface { border-color: color-mix(in srgb, var(--primary) 45%, var(--border)); color: var(--link); background: color-mix(in srgb, var(--primary) 12%, var(--surface)); }
|
|
.tag-model { border-color: color-mix(in srgb, #9333ea 45%, var(--border)); background: color-mix(in srgb, #9333ea 12%, var(--surface)); }
|
|
.tag-workflow { border-color: color-mix(in srgb, #0d9488 45%, var(--border)); background: color-mix(in srgb, #0d9488 12%, var(--surface)); }
|
|
.tag-integration { border-color: color-mix(in srgb, #ea580c 45%, var(--border)); background: color-mix(in srgb, #ea580c 12%, var(--surface)); }
|
|
.tag-origin { border-color: color-mix(in srgb, #db2777 45%, var(--border)); background: color-mix(in srgb, #db2777 12%, var(--surface)); }
|
|
.tag-model, .tag-workflow, .tag-integration, .tag-origin { color: var(--fg); }
|
|
/* Facet filter rows */
|
|
.facets { margin: 0 0 1rem; display: grid; gap: .4rem; }
|
|
.facet { display: flex; flex-wrap: wrap; gap: .4rem; align-items: center; }
|
|
.facet > .flabel {
|
|
font-size: .72rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted);
|
|
font-weight: 600; min-width: 6.5rem;
|
|
}
|
|
.facet button {
|
|
border: 1px solid var(--border); background: var(--surface); color: var(--fg);
|
|
border-radius: 999px; padding: .22rem .7rem; cursor: pointer; font: inherit; font-size: .82rem;
|
|
transition: background-color .15s ease, border-color .15s ease, color .15s ease;
|
|
}
|
|
.facet button:hover { border-color: var(--primary); }
|
|
.facet button[aria-pressed="true"] { background: var(--primary); color: var(--primary-fg); border-color: var(--primary); }
|
|
#clearfilters[hidden] { display: none; }
|
|
tr.stale td { color: var(--muted); }
|
|
.stalebadge {
|
|
display: inline-block; font-size: .7rem; padding: 0 .45rem; margin-left: .4rem;
|
|
border: 1px solid var(--border); border-radius: 999px; color: var(--muted); background: var(--surface-2);
|
|
text-transform: uppercase; letter-spacing: .03em; vertical-align: middle;
|
|
}
|
|
td.empty { text-align: center; color: var(--muted); padding: 1.5rem; }
|
|
/* Chart card */
|
|
h2 { font-size: 1.1rem; font-weight: 600; margin: 0; letter-spacing: -.01em; }
|
|
section.chartcard {
|
|
margin-top: 1.5rem; background: var(--surface); border: 1px solid var(--border);
|
|
border-radius: 12px; box-shadow: var(--shadow); padding: 1rem;
|
|
}
|
|
.charthead { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: .75rem; flex-wrap: wrap; }
|
|
.chartwrap { position: relative; height: 460px; }
|
|
.chartmsg { color: var(--muted); text-align: center; padding-top: 4rem; }
|
|
/* Hover readout. Chart.js paints its tooltip on the canvas, which cannot
|
|
bold one row, so the ranking is HTML and the built-in tooltip stays off. */
|
|
#hovercard {
|
|
position: absolute; left: 0; top: 0; z-index: 2; pointer-events: none; opacity: 0;
|
|
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
|
box-shadow: 0 4px 16px rgb(0 0 0 / 18%); padding: .5rem .6rem; max-width: 100%;
|
|
transition: opacity .12s ease;
|
|
}
|
|
#hovercard .hdate {
|
|
font-size: .7rem; text-transform: uppercase; letter-spacing: .06em;
|
|
color: var(--muted); font-weight: 600; margin-bottom: .35rem;
|
|
}
|
|
#hovercard .cols { display: flex; gap: .85rem; align-items: flex-start; }
|
|
#hovercard ul { list-style: none; margin: 0; padding: 0; }
|
|
/* Grid, not flex: every row in a column shares the template, so names
|
|
truncate at one width and the star counts line up. */
|
|
#hovercard li {
|
|
display: grid; grid-template-columns: .5rem 1.15rem minmax(0, 9.5rem) auto;
|
|
align-items: center; gap: .35rem; font-size: .72rem; line-height: 1.6;
|
|
color: var(--muted);
|
|
}
|
|
#hovercard li.on { color: var(--fg); font-weight: 700; }
|
|
#hovercard .sw { width: .5rem; height: .5rem; border-radius: 2px; opacity: .75; }
|
|
#hovercard li.on .sw { opacity: 1; }
|
|
#hovercard .rk { text-align: right; font-variant-numeric: tabular-nums; opacity: .75; }
|
|
#hovercard .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
#hovercard .val { text-align: right; font-variant-numeric: tabular-nums; padding-left: .5rem; }
|
|
/* Touch devices have no hover, so the card would only ever be in the way. */
|
|
@media (hover: none) { #hovercard { display: none; } }
|
|
.hint { color: var(--muted); font-size: .85rem; margin: .75rem 0 0; }
|
|
#err { color: var(--neg); }
|
|
footer { color: var(--muted); font-size: .85rem; margin: 1.5rem 0 .5rem; display: flex; gap: .5rem; flex-wrap: wrap; }
|
|
footer a { color: var(--link); }
|
|
noscript p { border: 1px solid var(--border); border-radius: 12px; padding: 1rem; background: var(--surface); }
|
|
noscript a { color: var(--link); }
|
|
@media (pointer: coarse) {
|
|
.chips button, .btn, #q, .ghlink { min-height: 44px; }
|
|
thead th { padding-block: .85rem; }
|
|
}
|
|
@media (max-width: 480px) { body { padding: 1rem; } .stats { grid-template-columns: 1fr 1fr; } }
|
|
@media (prefers-reduced-motion: reduce) {
|
|
#hovercard { transition: none; }
|
|
*, *::before, *::after { transition: none !important; animation: none !important; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header class="hero">
|
|
<div>
|
|
<h1>
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2l2.9 6.26L21.5 9.1l-4.9 4.5 1.3 6.7L12 17l-5.9 3.3 1.3-6.7-4.9-4.5 6.6-.84L12 2z"/></svg>
|
|
Awesome Coding Agents
|
|
</h1>
|
|
<div class="sub">Curated ranking of open-source AI coding agents by GitHub stars, refreshed daily. <span id="meta"></span></div>
|
|
</div>
|
|
<a class="ghlink" href="https://github.com/tiennm99/awesome-coding-agents">
|
|
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
|
|
GitHub
|
|
</a>
|
|
</header>
|
|
<div id="err" role="alert"></div>
|
|
|
|
<noscript>
|
|
<p>This dashboard needs JavaScript to render the ranking table and charts.
|
|
You can view the same ranking in the
|
|
<a href="https://github.com/tiennm99/awesome-coding-agents#readme">project README on GitHub</a>.</p>
|
|
</noscript>
|
|
|
|
<div class="stats" id="stats" hidden>
|
|
<div class="stat"><small>Tracked repos</small><b id="statTracked">—</b></div>
|
|
<div class="stat"><small>Total stars</small><b id="statStars">—</b></div>
|
|
<div class="stat"><small>Top 7-day gainer</small><b id="statGainer">—</b><span class="statsub pos" id="statGainerDelta"></span></div>
|
|
<div class="stat"><small>Last updated</small><b id="statUpdated">—</b><span class="statsub">UTC, daily via Actions</span></div>
|
|
</div>
|
|
|
|
<div class="toolbar">
|
|
<label class="vh" for="q">Search repositories by name, description, or tag</label>
|
|
<div class="searchbox">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
|
<input id="q" type="search" placeholder="Search repos…" autocomplete="off">
|
|
</div>
|
|
<button type="button" class="btn" id="clearfilters" hidden>Clear filters</button>
|
|
</div>
|
|
<div class="facets" id="facets"></div>
|
|
|
|
<div class="tablewrap"><table id="tbl">
|
|
<thead><tr>
|
|
<th scope="col" data-k="rank" class="num" tabindex="0">#</th>
|
|
<th scope="col" data-k="nameWithOwner" tabindex="0">Repo</th>
|
|
<th scope="col" data-k="stars" class="num" tabindex="0">Stars</th>
|
|
<th scope="col" data-k="delta7d" class="num" tabindex="0">Δ7d</th>
|
|
<th scope="col" data-k="delta30d" class="num" tabindex="0">Δ30d</th>
|
|
<th scope="col">Tags</th>
|
|
<th scope="col" data-k="language" tabindex="0">Language</th>
|
|
<th scope="col" data-k="pushedAt" tabindex="0">Last push</th>
|
|
<th scope="col">Description</th>
|
|
</tr></thead>
|
|
<tbody></tbody>
|
|
</table></div>
|
|
|
|
<section class="chartcard">
|
|
<div class="charthead">
|
|
<h2>Star history</h2>
|
|
<button type="button" id="scaleToggle" class="btn" aria-pressed="false">Log scale</button>
|
|
</div>
|
|
<div class="chartwrap"><canvas id="chart" role="img"
|
|
aria-label="Line chart of GitHub star history over time for the repositories listed in the table above."></canvas><div
|
|
id="hovercard" aria-hidden="true"></div></div>
|
|
<p class="hint">Every tracked repo is plotted — hover a date for the full ranking at that point, click legend entries to hide series. The tag filters and search above also filter the chart.</p>
|
|
</section>
|
|
|
|
<footer>
|
|
<span>Data refreshed daily by <a href="https://github.com/tiennm99/awesome-coding-agents/actions">GitHub Actions</a>.</span>
|
|
<span>Add an agent via <a href="https://github.com/tiennm99/awesome-coding-agents#contributing">a pull request</a>.</span>
|
|
</footer>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
|
|
<script>
|
|
(async function () {
|
|
let data;
|
|
try {
|
|
const res = await fetch('data.json', { cache: 'no-cache' });
|
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
data = await res.json();
|
|
} catch (e) {
|
|
document.getElementById('err').textContent = 'Failed to load data.json: ' + e.message;
|
|
return;
|
|
}
|
|
|
|
const rows = data.rows.map((r, i) => ({ ...r, rank: i + 1 }));
|
|
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
const fmtStars = n => n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'k' : String(n);
|
|
|
|
// KPI stat cards
|
|
document.getElementById('stats').hidden = false;
|
|
document.getElementById('statTracked').textContent = rows.length;
|
|
document.getElementById('statStars').textContent = fmtStars(rows.reduce((s, r) => s + r.stars, 0));
|
|
const gainer = rows.filter(r => r.hasDelta).sort((a, b) => b.delta7d - a.delta7d)[0];
|
|
if (gainer) {
|
|
document.getElementById('statGainer').textContent = gainer.nameWithOwner.split('/')[1];
|
|
document.getElementById('statGainerDelta').textContent = '+' + gainer.delta7d.toLocaleString() + ' stars this week';
|
|
}
|
|
document.getElementById('statUpdated').textContent = data.updatedAt.replace(' UTC', '');
|
|
|
|
const staleCutoff = new Date();
|
|
staleCutoff.setMonth(staleCutoff.getMonth() - 6);
|
|
const isStale = r => r.pushedAt && new Date(r.pushedAt) < staleCutoff;
|
|
|
|
// --- facet filter chips (multi-select) ---
|
|
// Vocabulary comes from site/data.json, which the Go updater fills from
|
|
// tagVocabulary — no second copy of the tag list lives in this file.
|
|
const facets = Array.isArray(data.facets) ? data.facets : [];
|
|
// Only offer a tag some row actually carries, so the rows never disagree
|
|
// with the chips after an agents.yml edit.
|
|
const inUse = new Set(rows.flatMap(r => r.tags || []));
|
|
const active = new Set();
|
|
const facetBox = document.getElementById('facets');
|
|
const clearBtn = document.getElementById('clearfilters');
|
|
|
|
const syncChips = () => {
|
|
facetBox.querySelectorAll('button[data-tag]').forEach(b => {
|
|
b.setAttribute('aria-pressed', String(active.has(b.dataset.tag)));
|
|
});
|
|
clearBtn.hidden = active.size === 0;
|
|
};
|
|
|
|
for (const f of facets) {
|
|
const usable = (f.tags || []).filter(t => inUse.has(t));
|
|
if (!usable.length) continue;
|
|
const row = document.createElement('div');
|
|
row.className = 'facet';
|
|
row.setAttribute('role', 'group');
|
|
row.setAttribute('aria-label', 'Filter by ' + f.label);
|
|
const label = document.createElement('span');
|
|
label.className = 'flabel';
|
|
label.textContent = f.label;
|
|
row.appendChild(label);
|
|
for (const tag of usable) {
|
|
const b = document.createElement('button');
|
|
b.type = 'button';
|
|
b.dataset.tag = tag;
|
|
b.dataset.facet = f.id;
|
|
b.textContent = tag;
|
|
b.setAttribute('aria-pressed', 'false');
|
|
b.onclick = () => {
|
|
active.has(tag) ? active.delete(tag) : active.add(tag);
|
|
syncChips(); renderTable(); renderChart();
|
|
};
|
|
row.appendChild(b);
|
|
}
|
|
facetBox.appendChild(row);
|
|
}
|
|
|
|
clearBtn.onclick = () => { active.clear(); syncChips(); renderTable(); renderChart(); };
|
|
|
|
// Selected tags grouped by facet: a row must match at least one tag in every
|
|
// facet that has a selection (OR inside a facet, AND across facets).
|
|
const selectionsByFacet = () => {
|
|
const groups = new Map();
|
|
for (const f of facets) {
|
|
const picked = (f.tags || []).filter(t => active.has(t));
|
|
if (picked.length) groups.set(f.id, picked);
|
|
}
|
|
return groups;
|
|
};
|
|
|
|
const matchesTags = r => {
|
|
const groups = selectionsByFacet();
|
|
if (!groups.size) return true;
|
|
const own = new Set(r.tags || []);
|
|
for (const picked of groups.values()) {
|
|
if (!picked.some(t => own.has(t))) return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
// --- text search (filters table + chart) ---
|
|
let query = '';
|
|
const qInput = document.getElementById('q');
|
|
let qTimer;
|
|
qInput.addEventListener('input', () => {
|
|
clearTimeout(qTimer);
|
|
qTimer = setTimeout(() => {
|
|
query = qInput.value.trim().toLowerCase();
|
|
renderTable(); renderChart();
|
|
}, 150);
|
|
});
|
|
|
|
const visible = () => rows.filter(r =>
|
|
matchesTags(r) &&
|
|
(!query ||
|
|
r.nameWithOwner.toLowerCase().includes(query) ||
|
|
String(r.description || '').toLowerCase().includes(query) ||
|
|
(r.tags || []).some(t => t.includes(query))));
|
|
|
|
// --- sortable table ---
|
|
let sortKey = 'rank', sortAsc = true;
|
|
document.querySelectorAll('thead th[data-k]').forEach(th => {
|
|
const activate = () => {
|
|
const k = th.dataset.k;
|
|
if (sortKey === k) sortAsc = !sortAsc;
|
|
else { sortKey = k; sortAsc = (k === 'rank' || k === 'nameWithOwner' || k === 'language'); }
|
|
renderTable();
|
|
};
|
|
th.onclick = activate;
|
|
th.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activate(); }
|
|
});
|
|
});
|
|
|
|
// tag → facet class; strict allowlist so third-party data can't inject class names
|
|
const facetOf = new Map(facets.flatMap(f => (f.tags || []).map(t => [t, f.id])));
|
|
const tagClass = t => /^[a-z][a-z0-9-]*$/.test(facetOf.get(t) || '') ? ' tag-' + facetOf.get(t) : '';
|
|
|
|
function renderTable() {
|
|
const list = [...visible()].sort((a, b) => {
|
|
if (sortKey === 'delta7d' || sortKey === 'delta30d') {
|
|
// repos without a baseline for this window always sort last, either direction
|
|
const hasKey = sortKey === 'delta7d' ? 'hasDelta' : 'hasDelta30';
|
|
if (a[hasKey] !== b[hasKey]) return a[hasKey] ? -1 : 1;
|
|
if (!a[hasKey]) return 0;
|
|
return sortAsc ? a[sortKey] - b[sortKey] : b[sortKey] - a[sortKey];
|
|
}
|
|
const va = a[sortKey], vb = b[sortKey];
|
|
const cmp = typeof va === 'number' ? va - vb : String(va).localeCompare(String(vb));
|
|
return sortAsc ? cmp : -cmp;
|
|
});
|
|
document.querySelectorAll('thead th[data-k]').forEach(th => {
|
|
th.querySelector('.dir')?.remove();
|
|
if (th.dataset.k === sortKey) {
|
|
th.setAttribute('aria-sort', sortAsc ? 'ascending' : 'descending');
|
|
th.insertAdjacentHTML('beforeend', ' <span class="dir" aria-hidden="true">' + (sortAsc ? '▲' : '▼') + '</span>');
|
|
} else {
|
|
th.removeAttribute('aria-sort');
|
|
}
|
|
});
|
|
|
|
const tbody = document.querySelector('#tbl tbody');
|
|
if (!list.length) {
|
|
tbody.innerHTML = '<tr><td class="empty" colspan="9">No repos match your filters.</td></tr>';
|
|
return;
|
|
}
|
|
tbody.innerHTML = list.map(r => `
|
|
<tr${(isStale(r) || r.archived) ? ' class="stale"' : ''}>
|
|
<td class="num rankcell">${r.rank}</td>
|
|
<td><a class="repo" href="${esc(r.url)}">${esc(r.nameWithOwner)}</a></td>
|
|
<td class="num">${fmtStars(r.stars)}</td>
|
|
<td class="num ${r.hasDelta && r.delta7d > 0 ? 'pos' : r.hasDelta && r.delta7d < 0 ? 'neg' : ''}">${r.hasDelta ? (r.delta7d > 0 ? '+' : '') + r.delta7d : '—'}</td>
|
|
<td class="num ${r.hasDelta30 && r.delta30d > 0 ? 'pos' : r.hasDelta30 && r.delta30d < 0 ? 'neg' : ''}">${r.hasDelta30 ? (r.delta30d > 0 ? '+' : '') + r.delta30d : '—'}</td>
|
|
<td class="tags">${(r.tags || []).length
|
|
? '<div class="taglist">' + r.tags.map(t => `<span class="tag${tagClass(t)}">${esc(t)}</span>`).join('') + '</div>'
|
|
: '—'}</td>
|
|
<td class="lang">${esc(r.language || '')}</td>
|
|
<td>${esc(r.pushedAt)}${isStale(r) ? ' <span class="stalebadge">stale</span>' : ''}${r.archived ? ' <span class="stalebadge">archived</span>' : ''}</td>
|
|
<td class="desc">${esc(r.description)}${r.notes ? ' · <em>' + esc(r.notes) + '</em>' : ''}</td>
|
|
</tr>`).join('');
|
|
}
|
|
|
|
renderTable();
|
|
|
|
// --- star-history line chart ---
|
|
const chartWrap = document.querySelector('.chartwrap');
|
|
if (!window.Chart) {
|
|
chartWrap.innerHTML = '<p class="chartmsg">Chart could not be loaded (the Chart.js CDN is unreachable or blocked). The ranking table above still works.</p>';
|
|
document.getElementById('scaleToggle').disabled = true;
|
|
return;
|
|
}
|
|
|
|
const dates = data.history.map(s => s.date);
|
|
const isDark = matchMedia('(prefers-color-scheme: dark)').matches;
|
|
// darker lines on light bg / lighter on dark bg to keep >=3:1 against background
|
|
const color = i => 'hsl(' + Math.round(i * 137.508) % 360 + ' 70% ' + (isDark ? 62 : 38) + '%)';
|
|
const styles = getComputedStyle(document.documentElement);
|
|
Chart.defaults.color = styles.getPropertyValue('--muted').trim();
|
|
Chart.defaults.borderColor = styles.getPropertyValue('--border').trim();
|
|
Chart.defaults.font.family = '"Fira Sans", sans-serif';
|
|
let chart;
|
|
let yType = 'linear';
|
|
const hiddenState = {}; // repo key -> hidden bool, survives chart re-renders
|
|
|
|
// --- hover readout ---
|
|
const hoverCard = document.getElementById('hovercard');
|
|
let hoverIdx = -1; // datasetIndex nearest the cursor, -1 when the cursor is away
|
|
|
|
// The series under the cursor stays solid and thick; the rest go thin and
|
|
// dashed, so one line reads clearly out of forty overlapping ones.
|
|
function paintEmphasis() {
|
|
chart.data.datasets.forEach((ds, i) => {
|
|
const on = i === hoverIdx;
|
|
ds.borderWidth = hoverIdx === -1 ? 2 : on ? 3.5 : 1;
|
|
ds.borderDash = hoverIdx === -1 || on ? [] : [4, 3];
|
|
ds.pointHoverRadius = on ? 4.5 : 0;
|
|
});
|
|
chart.update('none');
|
|
}
|
|
|
|
function hideHover() {
|
|
hoverCard.style.opacity = '0';
|
|
if (hoverIdx !== -1) {
|
|
hoverIdx = -1;
|
|
paintEmphasis(); // repaint runs once: hoverIdx is already -1 on re-entry
|
|
}
|
|
}
|
|
|
|
// Rank every plotted series at the hovered date, highest first, and bold the
|
|
// one under the cursor. Columns keep forty rows inside the chart card.
|
|
function showHover(tooltip) {
|
|
const items = tooltip.dataPoints
|
|
.filter(p => p.parsed.y != null)
|
|
.sort((a, b) => b.parsed.y - a.parsed.y);
|
|
if (!items.length) { hideHover(); return; }
|
|
|
|
const cols = items.length > 28 ? 3 : items.length > 14 ? 2 : 1;
|
|
const perCol = Math.ceil(items.length / cols);
|
|
let html = '<div class="hdate">' + esc(items[0].label) + '</div><div class="cols">';
|
|
for (let c = 0; c < cols; c++) {
|
|
html += '<ul>';
|
|
items.slice(c * perCol, (c + 1) * perCol).forEach((p, j) => {
|
|
// borderColor comes from color() in this file, never from data.json
|
|
html += '<li class="' + (p.datasetIndex === hoverIdx ? 'on' : '') + '">'
|
|
+ '<span class="sw" style="background:' + p.dataset.borderColor + '"></span>'
|
|
+ '<span class="rk">' + (c * perCol + j + 1) + '</span>'
|
|
+ '<span class="nm">' + esc(p.dataset.label) + '</span>'
|
|
+ '<span class="val">' + p.parsed.y.toLocaleString('en-US') + '</span></li>';
|
|
});
|
|
html += '</ul>';
|
|
}
|
|
hoverCard.innerHTML = html + '</div>';
|
|
|
|
// Place beside the caret, flipped or clamped to stay inside the card.
|
|
hoverCard.style.opacity = '1';
|
|
const wrap = chartWrap.getBoundingClientRect();
|
|
const card = hoverCard.getBoundingClientRect();
|
|
let left = tooltip.caretX + 18;
|
|
if (left + card.width > wrap.width) left = tooltip.caretX - card.width - 18;
|
|
hoverCard.style.left = Math.max(0, Math.min(left, Math.max(0, wrap.width - card.width))) + 'px';
|
|
hoverCard.style.top = Math.max(0, Math.min(tooltip.caretY - card.height / 2, Math.max(0, wrap.height - card.height))) + 'px';
|
|
}
|
|
|
|
function renderChart() {
|
|
// Degraded mode: filter/search handlers still call this after the early
|
|
// CDN-failure return; without the guard they hit TDZ consts and throw.
|
|
if (!window.Chart) return;
|
|
hoverIdx = -1;
|
|
hoverCard.style.opacity = '0';
|
|
const list = visible();
|
|
const datasets = list.map((r, i) => {
|
|
const pts = data.history.map(s => s.stars[r.key] ?? null);
|
|
const nPoints = pts.filter(v => v != null).length;
|
|
return {
|
|
label: r.nameWithOwner,
|
|
repoKey: r.key,
|
|
data: pts,
|
|
borderColor: color(i),
|
|
backgroundColor: color(i),
|
|
pointRadius: nPoints < 2 ? 3 : 0, // single snapshots need a visible dot
|
|
pointHoverRadius: 0, // paintEmphasis turns the dot on for the hovered series only
|
|
borderWidth: 2,
|
|
spanGaps: true,
|
|
hidden: hiddenState[r.key] ?? false, // every series plotted until the legend hides it
|
|
};
|
|
});
|
|
if (chart) chart.destroy();
|
|
chart = new Chart(document.getElementById('chart'), {
|
|
type: 'line',
|
|
data: { labels: dates, datasets },
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false, animation: false,
|
|
// index mode collects every series at the hovered date, which is what
|
|
// the readout ranks; onHover tracks which one the cursor is nearest.
|
|
interaction: { mode: 'index', intersect: false, axis: 'x' },
|
|
onHover(e, _active, ch) {
|
|
const near = ch.getElementsAtEventForMode(e, 'nearest', { intersect: false }, true);
|
|
const idx = near.length ? near[0].datasetIndex : -1;
|
|
if (idx !== hoverIdx) { hoverIdx = idx; paintEmphasis(); }
|
|
},
|
|
scales: {
|
|
y: { type: yType, ticks: { callback: v => fmtStars(v) } },
|
|
x: { ticks: { maxTicksLimit: 12 } },
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom',
|
|
labels: { boxWidth: 12, boxHeight: 12 },
|
|
onClick(e, item, legend) {
|
|
Chart.defaults.plugins.legend.onClick.call(this, e, item, legend);
|
|
const ds = legend.chart.data.datasets[item.datasetIndex];
|
|
hiddenState[ds.repoKey] = !legend.chart.isDatasetVisible(item.datasetIndex);
|
|
},
|
|
},
|
|
tooltip: {
|
|
enabled: false, // replaced by the HTML readout in showHover
|
|
external: ctx => ctx.tooltip.opacity ? showHover(ctx.tooltip) : hideHover(),
|
|
},
|
|
},
|
|
},
|
|
});
|
|
document.getElementById('chart').setAttribute('aria-label',
|
|
'Line chart of GitHub star history over time for ' + list.length + ' repositories. The same data is in the table above.');
|
|
}
|
|
|
|
document.getElementById('chart').addEventListener('mouseleave', hideHover);
|
|
|
|
const scaleBtn = document.getElementById('scaleToggle');
|
|
scaleBtn.onclick = () => {
|
|
yType = yType === 'linear' ? 'logarithmic' : 'linear';
|
|
scaleBtn.setAttribute('aria-pressed', String(yType === 'logarithmic'));
|
|
if (chart) { chart.options.scales.y.type = yType; chart.update(); }
|
|
};
|
|
|
|
renderChart();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|