|
|
<!doctype html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="UTF-8" /> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
|
<title>GitHub Stars Trending Score</title> |
|
|
<script src="https://cdn.tailwindcss.com"></script> |
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> |
|
|
</head> |
|
|
<body class="bg-gray-50 p-8 text-gray-900"> |
|
|
<h1 class="mb-4 text-2xl font-semibold">GitHub Stars & Trending Score (1 Week)</h1> |
|
|
<p class="mb-6 text-gray-600"> |
|
|
This demo shows how GitHub star activity changes over time with a trending score calculated using exponential |
|
|
moving average (EMA).<br /> |
|
|
Adjust the alpha parameter to see how it affects the smoothing of the trending score relative to raw star counts. |
|
|
</p> |
|
|
|
|
|
<div class="mb-6 flex flex-wrap gap-4"> |
|
|
<div> |
|
|
<label class="mb-2 block text-sm font-medium text-gray-700"> |
|
|
Alpha (EMA weight): <span id="alphaValue" class="ml-2 font-bold">0.40</span> |
|
|
</label> |
|
|
<input id="alphaSlider" type="range" min="0" max="1" step="0.01" value="0.4" class="w-64" /> |
|
|
</div> |
|
|
<div> |
|
|
<label class="mb-2 block text-sm font-medium text-gray-700"> </label> |
|
|
<button id="generateData" class="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"> |
|
|
Generate Data |
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="h-96 w-full rounded-lg bg-white shadow-sm"> |
|
|
<canvas id="trendChart" class="h-full w-full"></canvas> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
const UPDATES_PER_DAY = 8; |
|
|
const DAYS = 7; |
|
|
const TOTAL_UPDATES = UPDATES_PER_DAY * DAYS; |
|
|
|
|
|
function generateRealisticData() { |
|
|
const patterns = [ |
|
|
"consistent_growth", |
|
|
"flat", |
|
|
"declining", |
|
|
"spike_early", |
|
|
"spike_late", |
|
|
"gradual_increase", |
|
|
"volatile", |
|
|
]; |
|
|
|
|
|
const pattern = patterns[Math.floor(Math.random() * patterns.length)]; |
|
|
const starsPerHourData = []; |
|
|
|
|
|
for (let update = 0; update < TOTAL_UPDATES; update++) { |
|
|
let value; |
|
|
const progress = update / TOTAL_UPDATES; |
|
|
const noise = (Math.random() - 0.5) * 0.5; |
|
|
|
|
|
switch (pattern) { |
|
|
case "consistent_growth": |
|
|
value = 0.5 + progress * 3 + noise; |
|
|
break; |
|
|
case "flat": |
|
|
value = 1 + noise; |
|
|
break; |
|
|
case "declining": |
|
|
value = 3 - progress * 2.5 + noise; |
|
|
break; |
|
|
case "spike_early": |
|
|
|
|
|
if (update >= 2 && update <= 10) { |
|
|
const distance = Math.abs(update - 6); |
|
|
value = 1 + 15 * Math.max(0, 1 - distance / 4) + noise; |
|
|
} else { |
|
|
value = 1 + noise; |
|
|
} |
|
|
break; |
|
|
case "spike_late": |
|
|
|
|
|
if (update >= 44 && update <= 52) { |
|
|
const distance = Math.abs(update - 48); |
|
|
value = 1 + 12 * Math.max(0, 1 - distance / 4) + noise; |
|
|
} else { |
|
|
value = 1 + noise; |
|
|
} |
|
|
break; |
|
|
case "gradual_increase": |
|
|
value = 0.3 + Math.pow(progress, 1.5) * 4 + noise; |
|
|
break; |
|
|
case "volatile": |
|
|
value = 1 + Math.sin(update * 0.8) * 2 + Math.cos(update * 0.3) * 1 + noise; |
|
|
break; |
|
|
default: |
|
|
value = 1 + noise; |
|
|
} |
|
|
|
|
|
starsPerHourData.push(Math.max(0, value)); |
|
|
} |
|
|
|
|
|
return starsPerHourData; |
|
|
} |
|
|
|
|
|
let starsPerHour = generateRealisticData(); |
|
|
|
|
|
function calculateTrendingScore(alpha) { |
|
|
const scores = [starsPerHour[0]]; |
|
|
|
|
|
for (let i = 1; i < starsPerHour.length; i++) { |
|
|
|
|
|
scores[i] = alpha * starsPerHour[i] + (1 - alpha) * scores[i - 1]; |
|
|
} |
|
|
|
|
|
return scores; |
|
|
} |
|
|
|
|
|
const slider = document.getElementById("alphaSlider"); |
|
|
const alphaDisplay = document.getElementById("alphaValue"); |
|
|
const generateButton = document.getElementById("generateData"); |
|
|
|
|
|
let alpha = parseFloat(slider.value); |
|
|
let trendingScores = calculateTrendingScore(alpha); |
|
|
|
|
|
const labels = Array.from({ length: TOTAL_UPDATES }, (_, i) => { |
|
|
const day = Math.floor(i / UPDATES_PER_DAY) + 1; |
|
|
return `Day ${day}`; |
|
|
}); |
|
|
|
|
|
const chart = new Chart(document.getElementById("trendChart"), { |
|
|
type: "line", |
|
|
data: { |
|
|
labels, |
|
|
datasets: [ |
|
|
{ |
|
|
label: "Stars per hour", |
|
|
data: starsPerHour, |
|
|
type: "bar", |
|
|
borderColor: "#3b82f6", |
|
|
backgroundColor: "rgba(59, 130, 246, 0.6)", |
|
|
yAxisID: "y", |
|
|
}, |
|
|
{ |
|
|
label: "Trending score", |
|
|
data: trendingScores, |
|
|
type: "line", |
|
|
borderColor: "#ef4444", |
|
|
backgroundColor: "rgba(239, 68, 68, 0.1)", |
|
|
fill: false, |
|
|
tension: 0.3, |
|
|
yAxisID: "y", |
|
|
}, |
|
|
], |
|
|
}, |
|
|
options: { |
|
|
responsive: true, |
|
|
maintainAspectRatio: false, |
|
|
interaction: { mode: "index" }, |
|
|
scales: { |
|
|
y: { |
|
|
beginAtZero: true, |
|
|
title: { |
|
|
display: true, |
|
|
text: "Stars / Score", |
|
|
}, |
|
|
}, |
|
|
x: { |
|
|
title: { |
|
|
display: true, |
|
|
text: "Days since launch", |
|
|
}, |
|
|
}, |
|
|
}, |
|
|
plugins: { |
|
|
tooltip: { |
|
|
callbacks: { |
|
|
label: context => `${context.dataset.label}: ${context.parsed.y.toFixed(1)}`, |
|
|
}, |
|
|
}, |
|
|
}, |
|
|
}, |
|
|
}); |
|
|
|
|
|
slider.addEventListener("input", () => { |
|
|
alpha = parseFloat(slider.value); |
|
|
alphaDisplay.textContent = alpha.toFixed(2); |
|
|
trendingScores = calculateTrendingScore(alpha); |
|
|
chart.data.datasets[1].data = trendingScores; |
|
|
chart.update(); |
|
|
}); |
|
|
|
|
|
generateButton.addEventListener("click", () => { |
|
|
starsPerHour = generateRealisticData(); |
|
|
trendingScores = calculateTrendingScore(alpha); |
|
|
chart.data.datasets[0].data = starsPerHour; |
|
|
chart.data.datasets[1].data = trendingScores; |
|
|
chart.update(); |
|
|
}); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
|