Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
mbostock committed Oct 1, 2024
0 parents commit bd15f7f
Show file tree
Hide file tree
Showing 17 changed files with 2,571 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.DS_Store
.env
/dist/
node_modules/
yarn-error.log
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Open-source analytics

This is an [Observable Framework](https://observablehq.com/framework) app.
1 change: 1 addition & 0 deletions observablehq.cloud.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
schedule: daily
20 changes: 20 additions & 0 deletions observablehq.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export default {
title: "Open-source analytics",
head: '<link rel="icon" href="observable.png" type="image/png" sizes="32x32">',
root: "src",
theme: ["alt"],
dynamicPaths: [
"/@observablehq/framework/downloads-dark.svg",
"/@observablehq/framework/downloads.svg",
"/@observablehq/inputs/downloads-dark.svg",
"/@observablehq/inputs/downloads.svg",
"/@observablehq/plot/downloads-dark.svg",
"/@observablehq/plot/downloads.svg",
"/@observablehq/runtime/downloads-dark.svg",
"/@observablehq/runtime/downloads.svg",
"/d3/downloads-dark.svg",
"/d3/downloads.svg",
"/htl/downloads-dark.svg",
"/htl/downloads.svg"
]
};
27 changes: 27 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"type": "module",
"private": true,
"scripts": {
"clean": "rimraf src/.observablehq/cache",
"build": "observable build",
"dev": "observable preview",
"deploy": "observable deploy",
"observable": "observable"
},
"dependencies": {
"@observablehq/framework": "^1.12.0-alpha.5",
"@observablehq/plot": "^0.6.16",
"d3-array": "^3.2.4",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dotenv": "^16.4.5",
"jsdom": "^25.0.1"
},
"devDependencies": {
"@types/node": "^22.7.4",
"rimraf": "^5.0.5"
},
"engines": {
"node": ">=18"
}
}
1 change: 1 addition & 0 deletions src/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/.observablehq/cache/
5 changes: 5 additions & 0 deletions src/.observablehq/deploy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"projectId": "6b22f906aebb73e4",
"projectSlug": "oss-analytics",
"workspaceLogin": "observablehq"
}
13 changes: 13 additions & 0 deletions src/@[scope]/[name]/downloads-dark.svg.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {parseArgs} from "node:util";
import {getNpmDownloads} from "../../npm.js";
import {DailyPlot} from "../../DailyPlot.js";

const {
values: {scope, name}
} = parseArgs({
options: {scope: {type: "string"}, name: {type: "string"}}
});

const data = await getNpmDownloads(`@${scope}/${name}`);

process.stdout.write(DailyPlot(data, {foreground: "white", background: "black"}).outerHTML);
13 changes: 13 additions & 0 deletions src/@[scope]/[name]/downloads.svg.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {parseArgs} from "node:util";
import {getNpmDownloads} from "../../npm.js";
import {DailyPlot} from "../../DailyPlot.js";

const {
values: {scope, name}
} = parseArgs({
options: {scope: {type: "string"}, name: {type: "string"}}
});

const data = await getNpmDownloads(`@${scope}/${name}`);

process.stdout.write(DailyPlot(data).outerHTML);
88 changes: 88 additions & 0 deletions src/DailyPlot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import * as Plot from "@observablehq/plot";
import {quantile} from "d3-array";
import {JSDOM} from "jsdom";

export function DailyPlot(
data,
{
label,
x = "date",
y = "value",
max = quantile(data, 0.995, (d) => d[y]),
width,
height = 200,
round = true,
marginTop = 10,
annotations = [],
background = "white",
foreground = "black",
focus = "#26c1ad",
document = new JSDOM("").window.document,
...options
} = {}
) {
const d7 = (options) => Plot.windowY({k: 7, anchor: "start", strict: true}, options);
const d28 = (options) => Plot.windowY({k: 28, anchor: "start", strict: true}, options);
const plot = Plot.plot({
...options,
style: `color: ${foreground}; --plot-background: ${background};`,
document,
marginTop,
width,
height,
round,
y: {grid: true, domain: [0, max], label},
marks: [
Plot.axisY({anchor: "right", label: null, tickFormat: max >= 10e3 ? "s" : undefined}),
Plot.areaY(data, {x, y, curve: "step", fill: foreground, fillOpacity: 0.2, interval: "day"}), // prettier-ignore
Plot.ruleY([0]),
Plot.lineY(data, d7({x, y, strokeWidth: 1, stroke: focus, interval: "day"})),
Plot.lineY(data, d28({x, y, stroke: foreground, interval: "day"})),
Annotations(annotations, {x, stroke: background, fill: foreground})
]
});
plot.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns", "http://www.w3.org/2000/svg"); // prettier-ignore
plot.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xlink", "http://www.w3.org/1999/xlink"); // prettier-ignore
return plot;
}

export function Annotations(
data,
{
x = "date",
text = "text",
href = "href",
target = "_blank",
fill = "currentColor",
stroke = "white",
strokeOpacity = 0.1,
fontVariant = "tabular-nums",
frameAnchor = "top-right",
lineAnchor = "bottom",
rotate = -90,
dx = -3,
dy = 0,
transform,
clip = true
} = {}
) {
return Plot.marks(
Plot.ruleX(data, {x, stroke: fill, strokeOpacity, transform, clip}),
Plot.text(data, {
x,
text,
href,
target,
rotate,
dx,
dy,
frameAnchor,
lineAnchor,
fontVariant,
fill,
stroke,
transform,
clip
})
);
}
13 changes: 13 additions & 0 deletions src/[name]/downloads-dark.svg.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {parseArgs} from "node:util";
import {getNpmDownloads} from "../npm.js";
import {DailyPlot} from "../DailyPlot.js";

const {
values: {name}
} = parseArgs({
options: {name: {type: "string"}}
});

const data = await getNpmDownloads(name);

process.stdout.write(DailyPlot(data, {foreground: "white", background: "black"}).outerHTML);
13 changes: 13 additions & 0 deletions src/[name]/downloads.svg.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {parseArgs} from "node:util";
import {getNpmDownloads} from "../npm.js";
import {DailyPlot} from "../DailyPlot.js";

const {
values: {name}
} = parseArgs({
options: {name: {type: "string"}}
});

const data = await getNpmDownloads(name);

process.stdout.write(DailyPlot(data).outerHTML);
54 changes: 54 additions & 0 deletions src/github.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import "dotenv/config";

const {GITHUB_TOKEN} = process.env;

export async function github(
path,
{
authorization = GITHUB_TOKEN && `token ${GITHUB_TOKEN}`,
accept = "application/vnd.github.v3+json"
} = {}
) {
const url = new URL(path, "https://api.github.com");
const headers = {...(authorization && {authorization}), accept};
const response = await fetch(url, {headers});
if (!response.ok) throw new Error(`fetch error: ${response.status} ${url}`);
return {headers: response.headers, body: await response.json()};
}

export async function* githubList(path, {reverse = true, ...options} = {}) {
const url = new URL(path, "https://api.github.com");
url.searchParams.set("per_page", "100");
url.searchParams.set("page", "1");
const first = await github(String(url), options);
if (reverse) {
let prevUrl = findRelLink(first.headers, "last");
if (prevUrl) {
do {
const next = await github(prevUrl, options);
yield* next.body.reverse(); // reverse order
prevUrl = findRelLink(next.headers, "prev");
} while (prevUrl);
} else {
yield* first.body.reverse();
}
} else {
yield* first.body;
let nextUrl = findRelLink(first.headers, "next");
while (nextUrl) {
const next = await github(nextUrl, options);
yield* next.body; // natural order
nextUrl = findRelLink(next.headers, "next");
}
}
}

function findRelLink(headers, name) {
return headers
.get("link")
?.split(/,\s+/g)
.map((link) => link.split(/;\s+/g))
.find(([, rel]) => rel === `rel="${name}"`)?.[0]
.replace(/^</, "")
.replace(/>$/, "");
}
19 changes: 19 additions & 0 deletions src/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Open-source analytics

```js
function preview([name, lightFile, darkFile]) {
return html`<div class="card" style="max-width: 640px;">
<h2>Daily downloads of ${name}</h2>
<img style="max-width: 100%;" src=${(dark ? darkFile : lightFile).href}>
</div>`;
}
```

${[
["@observablehq/plot", FileAttachment("@observablehq/plot/downloads.svg"), FileAttachment("@observablehq/plot/downloads-dark.svg")],
["@observablehq/framework", FileAttachment("@observablehq/framework/downloads.svg"), FileAttachment("@observablehq/framework/downloads-dark.svg")],
["@observablehq/runtime", FileAttachment("@observablehq/runtime/downloads.svg"), FileAttachment("@observablehq/runtime/downloads-dark.svg")],
["@observablehq/inputs", FileAttachment("@observablehq/inputs/downloads.svg"), FileAttachment("@observablehq/inputs/downloads-dark.svg")],
["d3", FileAttachment("d3/downloads.svg"), FileAttachment("d3/downloads-dark.svg")],
["htl", FileAttachment("htl/downloads.svg"), FileAttachment("htl/downloads-dark.svg")]
].map(preview)}
37 changes: 37 additions & 0 deletions src/npm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {timeDay, utcDay} from "d3-time";
import {utcFormat} from "d3-time-format";

const formatDate = utcFormat("%Y-%m-%d");

export async function getNpmDownloads(
name,
{
start = new Date("2021-01-01"),
end = utcDay(timeDay()) // exclusive
} = {}
) {
const data = [];
let batchStart = end;
let batchEnd;
while (batchStart > start) {
batchEnd = batchStart;
batchStart = utcDay.offset(batchStart, -365);
if (batchStart < start) batchStart = start;
const response = await fetch(
`https://api.npmjs.org/downloads/range/${formatDate(batchStart)}:${formatDate(
utcDay.offset(batchEnd, -1)
)}/${name}`
);
if (!response.ok) throw new Error(`fetch failed: ${response.status}`);
const batch = await response.json();
for (const {downloads: value, day: date} of batch.downloads.reverse()) {
data.push({date: new Date(date), value});
}
}
for (let i = data.length - 1; i >= 0; --i) {
if (data[i].value > 0) {
return data.slice(data[0].value > 0 ? 0 : 1, i + 1); // ignore npm reporting zero for today
}
}
throw new Error("empty dataset");
}
Binary file added src/observable.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit bd15f7f

Please sign in to comment.