mirror of
https://github.com/tiennm99/awesome-ai-dev-tools.git
synced 2026-10-11 12:08:58 +00:00
The emphasis and the bolded row came from Chart.js index-mode interaction, which only recomputes when the x index changes. Moving straight up and down inside one date therefore kept whichever series was picked on entry, so the highlight did not match the line under the cursor. Track the pointer on the canvas instead and derive both the date and the nearest series from pixel distance, so vertical movement re-picks. The built-in tooltip and interaction config are gone; a small plugin draws the vertical guide at the hovered date and a dot where the emphasized series crosses it, replacing the caret and hover point that came with them. Reads are throttled to one per animation frame, and the card now follows the cursor's height instead of the average of forty series.
696 lines
33 KiB
HTML
696 lines
33 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 ---
|
|
// Hover is handled from the canvas rather than through Chart.js
|
|
// interaction modes: an index-mode tooltip only recomputes when the x index
|
|
// changes, so moving straight up and down inside one date never re-picked
|
|
// the series under the cursor. Tracking the pointer here makes both the
|
|
// emphasis and the bolded row follow vertical movement.
|
|
const canvas = document.getElementById('chart');
|
|
const hoverCard = document.getElementById('hovercard');
|
|
let hoverAt = -1; // x index under the cursor, -1 when the cursor is away
|
|
let hoverIdx = -1; // datasetIndex nearest the cursor, -1 when none
|
|
|
|
// Vertical guide at the hovered date plus a dot where the emphasized series
|
|
// crosses it — the anchor Chart.js's own caret would have drawn.
|
|
const crosshair = {
|
|
id: 'crosshair',
|
|
afterDatasetsDraw(c) {
|
|
if (hoverAt < 0) return;
|
|
const x = c.scales.x.getPixelForValue(hoverAt);
|
|
if (!Number.isFinite(x)) return;
|
|
const { ctx, chartArea } = c;
|
|
ctx.save();
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([3, 3]);
|
|
ctx.strokeStyle = Chart.defaults.borderColor;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, chartArea.top);
|
|
ctx.lineTo(x, chartArea.bottom);
|
|
ctx.stroke();
|
|
const ds = hoverIdx >= 0 ? c.data.datasets[hoverIdx] : null;
|
|
const v = ds ? ds.data[hoverAt] : null;
|
|
if (v != null) {
|
|
ctx.setLineDash([]);
|
|
ctx.fillStyle = ds.borderColor;
|
|
ctx.beginPath();
|
|
ctx.arc(x, c.scales.y.getPixelForValue(v), 4, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
},
|
|
};
|
|
|
|
// 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];
|
|
});
|
|
chart.update('none'); // dash and width are resolved at update time, not draw
|
|
}
|
|
|
|
function hideHover() {
|
|
hoverCard.style.opacity = '0';
|
|
const wasOn = hoverAt !== -1 || hoverIdx !== -1;
|
|
hoverAt = -1;
|
|
hoverIdx = -1;
|
|
if (wasOn && chart) paintEmphasis();
|
|
}
|
|
|
|
// 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 renderHoverCard(items) {
|
|
const cols = items.length > 28 ? 3 : items.length > 14 ? 2 : 1;
|
|
const perCol = Math.ceil(items.length / cols);
|
|
let html = '<div class="hdate">' + esc(dates[hoverAt]) + '</div><div class="cols">';
|
|
for (let c = 0; c < cols; c++) {
|
|
html += '<ul>';
|
|
items.slice(c * perCol, (c + 1) * perCol).forEach((it, j) => {
|
|
// colors come from color() in this file, never from data.json
|
|
html += '<li class="' + (it.i === hoverIdx ? 'on' : '') + '">'
|
|
+ '<span class="sw" style="background:' + it.color + '"></span>'
|
|
+ '<span class="rk">' + (c * perCol + j + 1) + '</span>'
|
|
+ '<span class="nm">' + esc(it.label) + '</span>'
|
|
+ '<span class="val">' + it.v.toLocaleString('en-US') + '</span></li>';
|
|
});
|
|
html += '</ul>';
|
|
}
|
|
hoverCard.innerHTML = html + '</div>';
|
|
}
|
|
|
|
// Beside the guide line, flipped or clamped to stay inside the chart card.
|
|
function placeHoverCard(anchorX, cursorY) {
|
|
hoverCard.style.opacity = '1';
|
|
const wrap = chartWrap.getBoundingClientRect();
|
|
const card = hoverCard.getBoundingClientRect();
|
|
let left = anchorX + 18;
|
|
if (left + card.width > wrap.width) left = anchorX - 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(cursorY - card.height / 2, Math.max(0, wrap.height - card.height))) + 'px';
|
|
}
|
|
|
|
function trackHover(x, y) {
|
|
if (!chart) return;
|
|
const { chartArea: area, scales } = chart;
|
|
if (x < area.left || x > area.right || y < area.top || y > area.bottom) { hideHover(); return; }
|
|
|
|
// Nearest date by pixel distance, using the same scale call the crosshair
|
|
// draws with, so the guide line always lands on the date being reported.
|
|
let idx = 0;
|
|
let idxDist = Infinity;
|
|
for (let i = 0; i < dates.length; i++) {
|
|
const dist = Math.abs(scales.x.getPixelForValue(i) - x);
|
|
if (dist < idxDist) { idxDist = dist; idx = i; }
|
|
}
|
|
|
|
// One pass: collect the plotted values at this date and, from their pixel
|
|
// positions, the series closest to the cursor's height.
|
|
const items = [];
|
|
let nearest = -1;
|
|
let nearestDist = Infinity;
|
|
chart.data.datasets.forEach((ds, i) => {
|
|
if (!chart.isDatasetVisible(i)) return;
|
|
const v = ds.data[idx];
|
|
if (v == null) return;
|
|
items.push({ i, v, label: ds.label, color: ds.borderColor });
|
|
const dist = Math.abs(scales.y.getPixelForValue(v) - y);
|
|
if (dist < nearestDist) { nearestDist = dist; nearest = i; }
|
|
});
|
|
if (!items.length) { hideHover(); return; }
|
|
items.sort((a, b) => b.v - a.v);
|
|
|
|
const movedDate = idx !== hoverAt;
|
|
const movedSeries = nearest !== hoverIdx;
|
|
hoverAt = idx;
|
|
hoverIdx = nearest;
|
|
|
|
if (movedDate || movedSeries) renderHoverCard(items);
|
|
placeHoverCard(scales.x.getPixelForValue(idx), y);
|
|
if (movedSeries) paintEmphasis(); // re-resolves dash and width, then redraws
|
|
else if (movedDate) chart.render(); // only the guide line moved
|
|
}
|
|
|
|
// One read per frame: mousemove fires far faster than the chart can redraw.
|
|
let framePending = false;
|
|
let cursor = null;
|
|
canvas.addEventListener('mousemove', e => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
cursor = { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
if (framePending) return;
|
|
framePending = true;
|
|
requestAnimationFrame(() => {
|
|
framePending = false;
|
|
if (cursor) trackHover(cursor.x, cursor.y);
|
|
});
|
|
});
|
|
canvas.addEventListener('mouseleave', () => { cursor = null; hideHover(); });
|
|
|
|
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;
|
|
hoverAt = -1;
|
|
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, // the crosshair plugin draws the dot instead
|
|
borderWidth: 2,
|
|
spanGaps: true,
|
|
hidden: hiddenState[r.key] ?? false, // every series plotted until the legend hides it
|
|
};
|
|
});
|
|
if (chart) chart.destroy();
|
|
chart = new Chart(canvas, {
|
|
type: 'line',
|
|
plugins: [crosshair],
|
|
data: { labels: dates, datasets },
|
|
options: {
|
|
responsive: true, maintainAspectRatio: false, animation: false,
|
|
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 above
|
|
},
|
|
},
|
|
});
|
|
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.');
|
|
}
|
|
|
|
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>
|