",
"repository": "antonmedv/spark",
"license": "MIT",
"dependencies": {
"delay": "^2.0.0",
"dotenv": "^5.0.1",
"koa": "^2.5.0",
"koa-route": "^3.2.0",
"koa-static": "^4.0.2",
"mz": "^2.7.0",
"r2": "^2.0.1"
}
}
================================================
FILE: page.js
================================================
const style = require('fs').readFileSync('style.css')
const rand = () => Math.round(100 * Math.random())
const layout = (content) => `
⚡️ GitHub Stars Sparklines
${content}
`
const box = (owner, name) => {
let title = owner + '/' + name
if (title.length > 20) {
title = name
}
return `
${title}
`
}
const a = (path) => {
const [owner, name] = path.split('/')
return `
${box(owner, name)}
`
}
const h1 = () => `⚡️ Spark GitHub Stars Sparklines
`
exports.index = () => layout(`
${h1()}
${a('facebook/react')}
${a('angular/angular')}
${a('vuejs/vue')}
${a('freeCodeCamp/freeCodeCamp')}
${a('jquery/jquery')}
${a('twbs/bootstrap')}
${a('rails/rails')}
${a('FortAwesome/Font-Awesome')}
${a('jashkenas/backbone')}
${a('php/php-src')}
${a('nodejs/node')}
${a('torvalds/linux')}
${a('moby/moby')}
${a('laravel/laravel')}
${a('reactjs/redux')}
${a('d3/d3')}
${a('axios/axios')}
${a('robbyrussell/oh-my-zsh')}
${a('facebook/react-native')}
${a('meteor/meteor')}
`)
exports.repo = ({owner, name}) => layout(`
${h1()}
${box(owner, name)}
The Sparkline shows GitHub stars velocity of ${owner + '/' + name} repo for the entire lifetime of the repository.
Add the Sparkline to repo's readme, copy markdown code below.
[](https://stars.medv.io/${owner + '/' + name})
`)
================================================
FILE: queue.js
================================================
const {render} = require('./render')
const delay = require('delay')
const queue = []
const set = new Set()
function size() {
return queue.length
}
async function worker() {
do {
try {
const work = queue.shift()
if (work) {
const [path, owner, name] = work
await render(path, owner, name)
set.delete(path)
} else {
await delay(100)
}
} catch (err) {
console.error(err)
}
} while (true)
}
function push(path, owner, name) {
if (!set.has(path)) {
queue.push([path, owner, name])
set.add(path)
}
}
function indexOf(path) {
return queue.findIndex(([p]) => path === p)
}
module.exports = {worker, push, size, indexOf}
================================================
FILE: render.js
================================================
const {dirname} = require('path')
const fs = require('mz/fs')
const {fetch} = require('./api')
const {createSvg} = require('./svg')
const total = new Map()
const query = `
query($owner: String!, $name: String!, $endCursor: String) {
repository(owner: $owner, name: $name) {
stargazers(first: 100, after: $endCursor) {
totalCount
edges {
starredAt
}
pageInfo {
hasNextPage
endCursor
}
}
}
rateLimit {
remaining
}
}
`
let rateLimit = {
remaining: 5000
}
async function render(path, owner, name) {
total.set(path, 0)
const data = await fetch(query, {owner, name})
if (data.repository) {
let {
stargazers: {
totalCount,
edges: dates,
pageInfo: {hasNextPage, endCursor}
}
} = data.repository
while (hasNextPage) {
total.set(path, Math.round(100 * dates.length / totalCount))
console.log(`${owner}/${name}: ${total.get(path)}%`)
const data = await fetch(query, {owner, name, endCursor})
hasNextPage = data.repository.stargazers.pageInfo.hasNextPage
endCursor = data.repository.stargazers.pageInfo.endCursor
rateLimit.remaining = data.rateLimit.remaining
dates = dates.concat(data.repository.stargazers.edges)
}
dates = dates.map(({starredAt}) => +(new Date(starredAt)))
const svg = createSvg(dates)
const dir = dirname(path)
await fs.exists(dir) || await fs.mkdir(dir)
fs.writeFile(path, svg)
}
total.delete(path)
}
module.exports = {render, total, rateLimit}
================================================
FILE: style.css
================================================
html {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
*, *:before, *:after {
-webkit-box-sizing: inherit;
-moz-box-sizing: inherit;
box-sizing: inherit;
padding: 0;
margin: 0;
}
body {
font-family: Helvetica, serif;
font-size: 14px;
line-height: 1.5;
color: rgba(0, 0, 0, .65);
background-color: #f9fbff;
}
.content {
display: flex;
flex-direction: column;
margin: 40px auto;
padding-left: 20px;
padding-right: 20px;
max-width: 1400px;
}
h1 {
margin-bottom: 50px;
font-size: 32px;
}
h1 > span {
font-size: 16px;
font-weight: normal;
}
@media only screen and (max-device-width: 350px) {
h1 > span {
display: block;
}
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-gap: 15px 15px;
}
a {
color: inherit;
text-decoration: none;
}
.box {
display: flex;
flex-direction: column;
padding: 10px 15px;
white-space: nowrap;
font-size: 16px;
font-weight: 500;
color: inherit;
background: #fff;
border-radius: 3px;
-webkit-box-shadow: 0 1px 3px rgba(0, 0, 0, .12), 0 1px 2px rgba(0, 0, 0, .24);
box-shadow: 0 1px 3px rgba(0, 0, 0, .12), 0 1px 2px rgba(0, 0, 0, .24);
-webkit-transition: all .3s cubic-bezier(.25, .8, .25, 1);
-o-transition: all .3s cubic-bezier(.25, .8, .25, 1);
transition: all .3s cubic-bezier(.25, .8, .25, 1);
}
.box:hover {
text-decoration: none;
color: inherit;
-webkit-transform: scale(1.01);
-ms-transform: scale(1.01);
transform: scale(1.01);
-webkit-box-shadow: 0 10px 20px rgba(0, 0, 0, .19), 0 6px 6px rgba(0, 0, 0, .23);
box-shadow: 0 10px 20px rgba(0, 0, 0, .19), 0 6px 6px rgba(0, 0, 0, .23);
}
.name {
font-weight: 500;
font-size: 18px;
margin-bottom: 5px;
padding-left: 20px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='currentColor' preserveAspectRatio='xMidYMid meet' height='1em' width='1em' viewBox='0 0 40 40' style='vertical-align: middle;'%3E%3Cg%3E%3Cpath d='m17.5 10h-2.5v2.5h2.5v-2.5z m0-5h-2.5v2.5h2.5v-2.5z m15-5h-25s-2.5 1.3-2.5 2.5v30s1.3 2.5 2.5 2.5h5v5l3.8-3.7 3.7 3.7v-5h12.5s2.5-1.2 2.5-2.5v-30s-1.2-2.5-2.5-2.5z m0 31.3c0 0.6-0.6 1.2-1.2 1.2h-11.3v-2.5h-7.5v2.5h-3.7s-1.3-0.7-1.3-1.2v-3.8h25v3.8z m0-6.3h-20v-22.5h20l0 22.5z m-15-5h-2.5v2.5h2.5v-2.5z m0-5h-2.5v2.5h2.5v-2.5z'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
background-position: 0 8px;
background-repeat: no-repeat;
overflow-x: hidden;
-o-text-overflow: ellipsis;
text-overflow: ellipsis;
}
.spark {
width: 200px;
align-self: center;
}
.one {
display: flex;
flex-direction: column;
}
.one-link {
display: inline-block;
align-self: center;
margin-top: 30px;
margin-bottom: 60px;
transform: scale(1.5);
}
.one > p {
display: block;
font-size: 16px;
margin-top: 10px;
max-width: 600px;
align-self: center;
}
code {
margin-top: 40px;
padding: 10px 15px;
max-width: 600px;
overflow-x: scroll;
align-self: center;
white-space: nowrap;
font-family: monospace;
background-color: #eeeeee;
}
@media only screen and (max-device-width: 812px) {
code {
width: 100%;
max-width: none;
}
}
.add-repo {
margin-top: 90px;
align-self: center;
}
.add-repo label {
display: block;
padding: 5px;
font-size: 16px;
text-align: center;
}
.add-repo input[type="text"] {
border: none;
border-radius: 4px;
outline: none;
padding: 10px 17px;
font-family: monospace;
font-size: 16px;
width: 200px;
background-color: #fff;
box-shadow: 2px 5px 10px rgb(228, 228, 228);
}
.add-repo button {
margin: 10px;
padding: 12px 12px;
cursor: pointer;
user-select: none;
text-align: center;
white-space: nowrap;
border: 0 none;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
line-height: 1.3;
-webkit-appearance: none;
-moz-appearance: none;
box-shadow: 2px 5px 10px rgb(228, 228, 228);
color: #7e8091;
background-color: #fff;
transition: background-color 150ms linear;
}
.add-repo button:hover {
background-color: #f2f2f2;
}
.r {
top: 0;
left: 0;
right: 0;
bottom: 0;
position: absolute;
z-index: -1;
overflow: hidden;
}
.r1, .r2, .r3, .r4{
position: relative;
width: 50px;
height: 50px;
border-radius: 100%;
border: solid 12px #fc607f;
}
.r2 {
border-color: #4580fe;
}
.r3 {
border-color: #fcb84d;
}
.r4 {
border-color: #78faca;
border-radius: 0;
}
================================================
FILE: svg.js
================================================
const height = 30
const x0 = 5
const y0 = 40
const steps = 40
const dx = 5
const basis = 1.5
const gradient = ['#3023AE', '#C86DD7']
const round = num => Math.round(num * 100) / 100
const vec = (x, y) => ({
x, y,
toString() {
return `${round(this.x)} ${round(this.y)}`
}
})
const norm = v => Math.sqrt(v.x * v.x + v.y * v.y)
const unit = v => {
const n = norm(v)
return vec(basis * v.x / n, basis * v.y / n)
}
const add = (a, b) => vec(a.x + b.x, a.y + b.y)
const sub = (a, b) => vec(a.x - b.x, a.y - b.y)
const end = px => px[px.length - 1]
function createSvg(data) {
if (data.length <= steps) {
return createTextSvg('⭐️ not enough stars')
}
const min = data[0]
const max = end(data)
const step = (max - min) / steps
const yx = []
{
let i = min
let count = 0
for (let d of data) {
if (d < i + step) {
count++
} else {
yx.push(count)
count = 1
i += step
}
}
}
const scale = Math.max(...yx) / height
{
for (let i = 0; i < yx.length; i++) {
yx[i] = round(yx[i] / scale)
}
}
const points = []
{
let x = x0
for (let y of yx) {
x += dx
points.push(vec(x, y0 - y))
}
}
const p0 = vec(x0, y0)
const p1 = points[0]
const p2 = points[1]
const c1 = add(p0, unit(sub(p1, p0)))
const c2 = add(p1, unit(sub(p0, p2)))
let path = `M${p0} C ${c1}, ${c2}, ${p1}`
for (let i = 1; i < points.length; i++) {
const p0 = points[i - 1]
const p1 = points[i]
const p2 = points[i + 1] || p1
const c = add(p1, unit(sub(p0, p2)))
path += ` S ${c}, ${p1}`
}
const pN = end(points)
return ``
}
function createTextSvg(text) {
return ``
}
module.exports = {createSvg, createTextSvg}
================================================
FILE: sync.js
================================================
require('dotenv').config()
const fs = require('mz/fs')
const {fetch} = require('./api')
const query = `
query ($endCursor: String) {
search(type: REPOSITORY, query: "stars:>8000", first: 100, after: $endCursor) {
repositoryCount
nodes {
... on Repository {
owner {
login
}
name
stargazers {
totalCount
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`
async function main() {
let {
search: {
repositoryCount,
nodes,
pageInfo: {hasNextPage, endCursor}
}
} = await fetch(query)
while (hasNextPage) {
const pt = Math.round(100 * nodes.length / repositoryCount)
console.log(`loading ${pt}% ${nodes.length}/${repositoryCount}`)
const data = await fetch(query, {endCursor})
endCursor = data.search.pageInfo.endCursor
hasNextPage = data.search.pageInfo.hasNextPage
nodes = nodes.concat(data.search.nodes)
}
console.log('Total repos: ' + nodes.length)
const db = {}
for (let node of nodes) {
const {
owner: {login},
name,
stargazers: {totalCount}
} = node
db[login + '/' + name] = totalCount
}
fs.writeFile('db.js', `module.exports = ${JSON.stringify(db, null, 2)}\n`)
}
main().catch(console.log)
================================================
FILE: ttl.js
================================================
const db = require('./db')
function ttl(repo) {
const stars = db[repo] || 0
if (stars > 10000) {
return 604800 // one week
} else if (stars > 8000) {
return 3 * 86400 // three days
} else {
return 86400 // one day
}
}
module.exports = ttl