Spaces:
Build error
Build error
create dockerfile and app
Browse files- Dockerfile +30 -0
- app/.gitignore +4 -0
- app/README.md +53 -0
- app/observablehq.config.js +37 -0
- app/package-lock.json +0 -0
- app/package.json +23 -0
- app/src/.gitignore +1 -0
- app/src/components/fetch-and-retry.js +29 -0
- app/src/data/categories.csv.js +18 -0
- app/src/data/posts.csv.js +40 -0
- app/src/data/setup.json +3 -0
- app/src/index.md +383 -0
- app/src/observable.png +0 -0
Dockerfile
ADDED
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# For Hugging Face, see https://huggingface.co/blog/severo/build-static-html-spaces
|
2 |
+
|
3 |
+
FROM ubuntu:22.04
|
4 |
+
|
5 |
+
# Install Node.js 22 - https://github.com/nodesource/distributions?tab=readme-ov-file#installation-instructions-deb
|
6 |
+
RUN curl -fsSL https://deb.nodesource.com/setup_22.x -o nodesource_setup.sh \
|
7 |
+
&& bash nodesource_setup.sh \
|
8 |
+
&& apt update \
|
9 |
+
&& apt install -y nodejs \
|
10 |
+
&& rm -rf /var/lib/apt/lists/* \
|
11 |
+
&& rm nodesource_setup.sh
|
12 |
+
|
13 |
+
# Build the app
|
14 |
+
WORKDIR /tmp/app
|
15 |
+
COPY app/ ./
|
16 |
+
RUN npm ci && npm rebuild && npm run build
|
17 |
+
|
18 |
+
# The site space name must be passed as an environment variable
|
19 |
+
# https://huggingface.co/docs/hub/spaces-sdks-docker#buildtime
|
20 |
+
ARG STATIC_SPACE
|
21 |
+
# The Hugging Face token must be passed as a secret (https://huggingface.co/docs/hub/spaces-sdks-docker#buildtime)
|
22 |
+
# 1. get README.md from the site space
|
23 |
+
RUN --mount=type=secret,id=HF_TOKEN,mode=0444,required=true \
|
24 |
+
huggingface-cli download --token=$(cat /run/secrets/HF_TOKEN) --repo-type=space --local-dir=/tmp/app/dist $STATIC_SPACE README.md && rm -rf /tmp/app/dist/.cache
|
25 |
+
# 2. upload the new build to the site space, including README.md
|
26 |
+
RUN --mount=type=secret,id=HF_TOKEN,mode=0444,required=true \
|
27 |
+
huggingface-cli upload --token=$(cat /run/secrets/HF_TOKEN) --repo-type=space $STATIC_SPACE /tmp/app/dist . --delete "*"
|
28 |
+
|
29 |
+
# Halt execution because the code space is not meant to run.
|
30 |
+
RUN exit 1
|
app/.gitignore
ADDED
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
1 |
+
.DS_Store
|
2 |
+
/dist/
|
3 |
+
node_modules/
|
4 |
+
yarn-error.log
|
app/README.md
ADDED
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Observable Forum Dashboard
|
2 |
+
|
3 |
+
This is an [Observable Framework](https://observablehq.com/framework) app. To start the local preview server, run:
|
4 |
+
|
5 |
+
```
|
6 |
+
npm run dev
|
7 |
+
```
|
8 |
+
|
9 |
+
Then visit <http://localhost:3000> to preview your app.
|
10 |
+
|
11 |
+
For more, see <https://observablehq.com/framework/getting-started>.
|
12 |
+
|
13 |
+
## Project structure
|
14 |
+
|
15 |
+
A typical Framework project looks like this:
|
16 |
+
|
17 |
+
```ini
|
18 |
+
.
|
19 |
+
├─ src
|
20 |
+
│ ├─ components
|
21 |
+
│ │ └─ timeline.js # an importable module
|
22 |
+
│ ├─ data
|
23 |
+
│ │ ├─ launches.csv.js # a data loader
|
24 |
+
│ │ └─ events.json # a static data file
|
25 |
+
│ ├─ example-dashboard.md # a page
|
26 |
+
│ ├─ example-report.md # another page
|
27 |
+
│ └─ index.md # the home page
|
28 |
+
├─ .gitignore
|
29 |
+
├─ observablehq.config.js # the app config file
|
30 |
+
├─ package.json
|
31 |
+
└─ README.md
|
32 |
+
```
|
33 |
+
|
34 |
+
**`src`** - This is the “source root” — where your source files live. Pages go here. Each page is a Markdown file. Observable Framework uses [file-based routing](https://observablehq.com/framework/routing), which means that the name of the file controls where the page is served. You can create as many pages as you like. Use folders to organize your pages.
|
35 |
+
|
36 |
+
**`src/index.md`** - This is the home page for your app. You can have as many additional pages as you’d like, but you should always have a home page, too.
|
37 |
+
|
38 |
+
**`src/data`** - You can put [data loaders](https://observablehq.com/framework/loaders) or static data files anywhere in your source root, but we recommend putting them here.
|
39 |
+
|
40 |
+
**`src/components`** - You can put shared [JavaScript modules](https://observablehq.com/framework/javascript/imports) anywhere in your source root, but we recommend putting them here. This helps you pull code out of Markdown files and into JavaScript modules, making it easier to reuse code across pages, write tests and run linters, and even share code with vanilla web applications.
|
41 |
+
|
42 |
+
**`observablehq.config.js`** - This is the [app configuration](https://observablehq.com/framework/config) file, such as the pages and sections in the sidebar navigation, and the app’s title.
|
43 |
+
|
44 |
+
## Command reference
|
45 |
+
|
46 |
+
| Command | Description |
|
47 |
+
| ----------------- | -------------------------------------------------------- |
|
48 |
+
| `npm install` | Install or reinstall dependencies |
|
49 |
+
| `npm run dev` | Start local preview server |
|
50 |
+
| `npm run build` | Build your static site, generating `./dist` |
|
51 |
+
| `npm run deploy` | Deploy your app to Observable |
|
52 |
+
| `npm run clean` | Clear the local data loader cache |
|
53 |
+
| `npm run observable` | Run commands like `observable help` |
|
app/observablehq.config.js
ADDED
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// See https://observablehq.com/framework/config for documentation.
|
2 |
+
export default {
|
3 |
+
// The app’s title; used in the sidebar and webpage titles.
|
4 |
+
title: "Observable Forum Dashboard",
|
5 |
+
|
6 |
+
// The pages and sections in the sidebar. If you don’t specify this option,
|
7 |
+
// all pages will be listed in alphabetical order. Listing pages explicitly
|
8 |
+
// lets you organize them into sections and have unlisted pages.
|
9 |
+
// pages: [
|
10 |
+
// {
|
11 |
+
// name: "Examples",
|
12 |
+
// pages: [
|
13 |
+
// {name: "Dashboard", path: "/example-dashboard"},
|
14 |
+
// {name: "Report", path: "/example-report"}
|
15 |
+
// ]
|
16 |
+
// }
|
17 |
+
// ],
|
18 |
+
|
19 |
+
// Content to add to the head of the page, e.g. for a favicon:
|
20 |
+
head: '<link rel="icon" href="observable.png" type="image/png" sizes="32x32">',
|
21 |
+
|
22 |
+
// The path to the source root.
|
23 |
+
root: "src",
|
24 |
+
|
25 |
+
// Some additional configuration options and their defaults:
|
26 |
+
// theme: "default", // try "light", "dark", "slate", etc.
|
27 |
+
// header: "", // what to show in the header (HTML)
|
28 |
+
// footer: "Built with Observable.", // what to show in the footer (HTML)
|
29 |
+
// sidebar: true, // whether to show the sidebar
|
30 |
+
// toc: true, // whether to show the table of contents
|
31 |
+
// pager: true, // whether to show previous & next links in the footer
|
32 |
+
// output: "dist", // path to the output root for build
|
33 |
+
// search: true, // activate search
|
34 |
+
// linkify: true, // convert URLs in Markdown to links
|
35 |
+
// typographer: false, // smart quotes and other typographic improvements
|
36 |
+
// cleanUrls: true, // drop .html from URLs
|
37 |
+
};
|
app/package-lock.json
ADDED
The diff for this file is too large to render.
See raw diff
|
|
app/package.json
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"type": "module",
|
3 |
+
"private": true,
|
4 |
+
"scripts": {
|
5 |
+
"clean": "rimraf src/.observablehq/cache",
|
6 |
+
"build": "observable build",
|
7 |
+
"dev": "observable preview",
|
8 |
+
"deploy": "observable deploy",
|
9 |
+
"observable": "observable"
|
10 |
+
},
|
11 |
+
"dependencies": {
|
12 |
+
"@observablehq/framework": "^1.11.0",
|
13 |
+
"d3-array": "^3.2.4",
|
14 |
+
"d3-dsv": "^3.0.1",
|
15 |
+
"d3-time-format": "^4.1.0"
|
16 |
+
},
|
17 |
+
"devDependencies": {
|
18 |
+
"rimraf": "^5.0.5"
|
19 |
+
},
|
20 |
+
"engines": {
|
21 |
+
"node": ">=18"
|
22 |
+
}
|
23 |
+
}
|
app/src/.gitignore
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
/.observablehq/cache/
|
app/src/components/fetch-and-retry.js
ADDED
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
const sleep = async (delay) =>
|
2 |
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
3 |
+
|
4 |
+
export const fetchAndRetry = async (
|
5 |
+
url,
|
6 |
+
options,
|
7 |
+
{ fetchAfterMs = 200, retriesAfterMs = [200, 5 * 1000, 60 * 1000] } = {}
|
8 |
+
) => {
|
9 |
+
let error;
|
10 |
+
for (let retryAfterMs of retriesAfterMs) {
|
11 |
+
try {
|
12 |
+
await sleep(fetchAfterMs);
|
13 |
+
const response = await fetch(url, options);
|
14 |
+
if (!response.ok) {
|
15 |
+
console.log(
|
16 |
+
`Failed to fetch ${url}: ${response.status} ${
|
17 |
+
response.statusText
|
18 |
+
} - Headers: ${JSON.stringify(response.headers)}`
|
19 |
+
);
|
20 |
+
await sleep(response.headers.get("Retry-After") ?? retryAfterMs);
|
21 |
+
continue;
|
22 |
+
}
|
23 |
+
return response;
|
24 |
+
} catch (e) {
|
25 |
+
error = e;
|
26 |
+
}
|
27 |
+
}
|
28 |
+
throw error;
|
29 |
+
};
|
app/src/data/categories.csv.js
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { csvFormat } from "d3-dsv";
|
2 |
+
import setup from "./setup.json" with { type: "json" };
|
3 |
+
import {fetchAndRetry} from "../components/fetch-and-retry.js";
|
4 |
+
|
5 |
+
const url = setup.base_url + "/categories.json";
|
6 |
+
const response = await fetchAndRetry(url);
|
7 |
+
const json = await response.json();
|
8 |
+
const categories = json.category_list.categories.map((d) => ({
|
9 |
+
id: d.id,
|
10 |
+
name: d.name,
|
11 |
+
color: d.color,
|
12 |
+
// description: d.description,
|
13 |
+
// description_text: d.description_text,
|
14 |
+
// subcategory_ids: d.subcategory_ids,
|
15 |
+
}));
|
16 |
+
|
17 |
+
// Write out csv formatted data.
|
18 |
+
process.stdout.write(csvFormat(categories));
|
app/src/data/posts.csv.js
ADDED
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { csvFormat } from "d3-dsv";
|
2 |
+
import setup from "./setup.json" with { type: "json" };
|
3 |
+
import {fetchAndRetry} from "../components/fetch-and-retry.js";
|
4 |
+
|
5 |
+
const postsChunks = [];
|
6 |
+
const MAX_REQUESTS = 10000;
|
7 |
+
let before = undefined;
|
8 |
+
let i = 0;
|
9 |
+
while (i++ < MAX_REQUESTS) {
|
10 |
+
const url =
|
11 |
+
setup.base_url + "/posts.json" + (before
|
12 |
+
? "?" + new URLSearchParams({
|
13 |
+
before,
|
14 |
+
}).toString()
|
15 |
+
: "");
|
16 |
+
const response = await fetchAndRetry(url);
|
17 |
+
|
18 |
+
const json = await response.json();
|
19 |
+
const newPosts = json.latest_posts
|
20 |
+
.map((d) => ({
|
21 |
+
id: d.id,
|
22 |
+
topic_id: d.topic_id,
|
23 |
+
username: d.username,
|
24 |
+
avatar_template: d.avatar_template,
|
25 |
+
created_at: d.created_at,
|
26 |
+
incoming_link_count: d.incoming_link_count,
|
27 |
+
reads: d.reads,
|
28 |
+
category_id: d.category_id,
|
29 |
+
accepted_answer: d.accepted_answer,
|
30 |
+
}))
|
31 |
+
.filter((d) => d.id !== before);
|
32 |
+
postsChunks.push(newPosts);
|
33 |
+
if (!newPosts.length) {
|
34 |
+
break;
|
35 |
+
}
|
36 |
+
before = newPosts[newPosts.length - 1].id;
|
37 |
+
}
|
38 |
+
|
39 |
+
// Write out csv formatted data.
|
40 |
+
process.stdout.write(csvFormat(postsChunks.flat()));
|
app/src/data/setup.json
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"base_url": "https://discuss.huggingface.co/"
|
3 |
+
}
|
app/src/index.md
ADDED
@@ -0,0 +1,383 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
---
|
2 |
+
theme: dashboard
|
3 |
+
toc: false
|
4 |
+
---
|
5 |
+
|
6 |
+
# Forum Dashboard
|
7 |
+
|
8 |
+
<!-- Load and transform the data -->
|
9 |
+
|
10 |
+
```js
|
11 |
+
import * as d3 from "d3-array";
|
12 |
+
const setup = await FileAttachment("data/setup.json").json();
|
13 |
+
const url = setup.base_url;
|
14 |
+
const posts = await FileAttachment("data/posts.csv").csv({ typed: true });
|
15 |
+
const categoriesRaw = await FileAttachment("data/categories.csv").csv({
|
16 |
+
typed: true,
|
17 |
+
});
|
18 |
+
const topics = [
|
19 |
+
...d3
|
20 |
+
.rollup(
|
21 |
+
posts,
|
22 |
+
(v) => ({
|
23 |
+
topic_id: v[0].topic_id,
|
24 |
+
category_id: v[0].category_id,
|
25 |
+
posts: v,
|
26 |
+
users: new Set(v.map((d) => d.username)),
|
27 |
+
}),
|
28 |
+
(d) => d.topic_id
|
29 |
+
)
|
30 |
+
.values(),
|
31 |
+
];
|
32 |
+
const users = d3.rollup(
|
33 |
+
posts,
|
34 |
+
(v) => ({ username: v[0].username, avatar_template: v[0].avatar_template }),
|
35 |
+
(d) => d.username
|
36 |
+
);
|
37 |
+
|
38 |
+
const topicsByCategory = d3.rollup(
|
39 |
+
topics,
|
40 |
+
(v) => v.length,
|
41 |
+
(d) => d.category_id
|
42 |
+
);
|
43 |
+
const categories = categoriesRaw.map((d) => ({
|
44 |
+
...d,
|
45 |
+
topics: topicsByCategory.get(d.id) || 0,
|
46 |
+
}));
|
47 |
+
|
48 |
+
const tenTopUsers = d3
|
49 |
+
.rollups(
|
50 |
+
posts,
|
51 |
+
(v) => v.length,
|
52 |
+
(d) => d.username
|
53 |
+
)
|
54 |
+
.sort((a, b) => d3.descending(a[1], b[1]))
|
55 |
+
.slice(0, 10)
|
56 |
+
.map((d) => ({
|
57 |
+
username: d[0],
|
58 |
+
posts: d[1],
|
59 |
+
}));
|
60 |
+
|
61 |
+
const tenTopAcceptedUsers = d3
|
62 |
+
.rollups(
|
63 |
+
posts.filter((d) => d.accepted_answer),
|
64 |
+
(v) => v.length,
|
65 |
+
(d) => d.username
|
66 |
+
)
|
67 |
+
.sort((a, b) => d3.descending(a[1], b[1]))
|
68 |
+
.slice(0, 10)
|
69 |
+
.map((d) => ({
|
70 |
+
username: d[0],
|
71 |
+
posts: d[1],
|
72 |
+
}));
|
73 |
+
|
74 |
+
const NUM_USERS = 3;
|
75 |
+
const topAcceptedUsersPerYear = d3
|
76 |
+
.rollups(
|
77 |
+
posts.filter((d) => d.accepted_answer),
|
78 |
+
(v) => v.length,
|
79 |
+
(d) => d.created_at.getFullYear(),
|
80 |
+
(d) => d.username
|
81 |
+
)
|
82 |
+
.flatMap(([year, users_stats]) => {
|
83 |
+
const top_usernames = users_stats
|
84 |
+
.sort(([_, posts_count_a], [__, posts_count_b]) =>
|
85 |
+
d3.descending(posts_count_a, posts_count_b)
|
86 |
+
)
|
87 |
+
.slice(0, NUM_USERS)
|
88 |
+
.map(([username]) => username);
|
89 |
+
return top_usernames.map((username, i) => ({
|
90 |
+
rank: i + 1,
|
91 |
+
year,
|
92 |
+
username,
|
93 |
+
src: url + users.get(username).avatar_template.replace("{size}", "400"),
|
94 |
+
}));
|
95 |
+
});
|
96 |
+
|
97 |
+
const intervals = { month: "Month", year: "Year", day: "Day", week: "Week" };
|
98 |
+
const interval = "month";
|
99 |
+
const intervalLabel = intervals[interval];
|
100 |
+
|
101 |
+
const color = {
|
102 |
+
users: "#e36209",
|
103 |
+
posts: "#3b5fc0",
|
104 |
+
accepted: "green",
|
105 |
+
};
|
106 |
+
|
107 |
+
const years = d3.extent(posts, (d) => d.created_at.getFullYear());
|
108 |
+
```
|
109 |
+
|
110 |
+
## Trends over time
|
111 |
+
|
112 |
+
<!-- Cards with big numbers -->
|
113 |
+
|
114 |
+
<div class="grid grid-cols-4">
|
115 |
+
<div class="card">
|
116 |
+
<h2>Years</h2>
|
117 |
+
<span class="big">${years[0]}—${years[1]}</span>
|
118 |
+
</div>
|
119 |
+
<div class="card">
|
120 |
+
<h2>Topics</h2>
|
121 |
+
<span class="big">${topics.length.toLocaleString("en-US")}</span>
|
122 |
+
<p>Topics are the forum's questions, or threads.</p>
|
123 |
+
</div>
|
124 |
+
<div class="card">
|
125 |
+
<h2>Posts</h2>
|
126 |
+
<span class="big">${posts.length.toLocaleString("en-US")}</span>
|
127 |
+
<p>The posts are comments in a thread, ie the answers to a question. The topics are not included.</p>
|
128 |
+
</div>
|
129 |
+
<!-- <div class="card">
|
130 |
+
<h2>Posts per topic</h2>
|
131 |
+
<span class="big">${(posts.length / topics.size).toLocaleString("en-US", {
|
132 |
+
minimumFractionDigits: 2,
|
133 |
+
maximumFractionDigits: 2,
|
134 |
+
})}</span>
|
135 |
+
</div>
|
136 |
+
<div class="card">
|
137 |
+
<h2>Users per topic</h2>
|
138 |
+
<span class="big">${(d3.sum(topics, d => d[1].users.size) / topics.size).toLocaleString("en-US", {
|
139 |
+
minimumFractionDigits: 2,
|
140 |
+
maximumFractionDigits: 2,
|
141 |
+
})}</span>
|
142 |
+
</div> -->
|
143 |
+
<!-- <div class="card">
|
144 |
+
<h2>Categories</h2>
|
145 |
+
<span class="big">${categories.length.toLocaleString("en-US")}</span>
|
146 |
+
</div> -->
|
147 |
+
<div class="card">
|
148 |
+
<h2>Users</h2>
|
149 |
+
<span class="big">${users.size.toLocaleString("en-US")}</span>
|
150 |
+
</div>
|
151 |
+
</div>
|
152 |
+
|
153 |
+
<!-- Plot of monthly active users -->
|
154 |
+
|
155 |
+
```js
|
156 |
+
function postsMAU(data, { width } = {}) {
|
157 |
+
return Plot.plot({
|
158 |
+
title: `Monthly active users`,
|
159 |
+
width,
|
160 |
+
height: 300,
|
161 |
+
y: { grid: true, label: `users` },
|
162 |
+
// color: {...color, legend: true},
|
163 |
+
marks: [
|
164 |
+
Plot.lineY(
|
165 |
+
data,
|
166 |
+
Plot.binX(
|
167 |
+
{ y: "distinct" },
|
168 |
+
{
|
169 |
+
x: "created_at",
|
170 |
+
y: "username",
|
171 |
+
stroke: color.users,
|
172 |
+
interval: "month",
|
173 |
+
tip: true,
|
174 |
+
}
|
175 |
+
)
|
176 |
+
),
|
177 |
+
Plot.ruleY([0]),
|
178 |
+
],
|
179 |
+
});
|
180 |
+
}
|
181 |
+
```
|
182 |
+
|
183 |
+
<!-- Plot of posts history -->
|
184 |
+
|
185 |
+
```js
|
186 |
+
function postsTimeline(data, { width } = {}) {
|
187 |
+
return Plot.plot({
|
188 |
+
title: `Posts created every ${interval}`,
|
189 |
+
width,
|
190 |
+
height: 300,
|
191 |
+
y: { grid: true, label: "posts" },
|
192 |
+
// color: {...color, legend: true},
|
193 |
+
marks: [
|
194 |
+
Plot.lineY(
|
195 |
+
data,
|
196 |
+
Plot.binX(
|
197 |
+
{ y: "count" },
|
198 |
+
{ x: "created_at", stroke: color.posts, interval, tip: true }
|
199 |
+
)
|
200 |
+
),
|
201 |
+
Plot.ruleY([0]),
|
202 |
+
],
|
203 |
+
});
|
204 |
+
}
|
205 |
+
```
|
206 |
+
|
207 |
+
<div class="grid grid-cols-2">
|
208 |
+
<div class="card">
|
209 |
+
${resize((width) => postsMAU(posts, {width}))}
|
210 |
+
</div>
|
211 |
+
<div class="card">
|
212 |
+
${resize((width) => postsTimeline(posts, {width}))}
|
213 |
+
</div>
|
214 |
+
</div>
|
215 |
+
|
216 |
+
## Topics
|
217 |
+
|
218 |
+
<!-- Plot of topics per category -->
|
219 |
+
|
220 |
+
```js
|
221 |
+
function categoriesChart(data, { width }) {
|
222 |
+
return Plot.plot({
|
223 |
+
title: "Most active categories",
|
224 |
+
width,
|
225 |
+
height: 300,
|
226 |
+
marginTop: 0,
|
227 |
+
marginLeft: 150,
|
228 |
+
x: { grid: true, label: "Topics" },
|
229 |
+
y: { label: null },
|
230 |
+
marks: [
|
231 |
+
Plot.barX(data, {
|
232 |
+
x: "topics",
|
233 |
+
y: "name",
|
234 |
+
fill: (d) => "#" + d.color,
|
235 |
+
tip: true,
|
236 |
+
sort: { y: "-x" },
|
237 |
+
}),
|
238 |
+
Plot.ruleX([0]),
|
239 |
+
],
|
240 |
+
});
|
241 |
+
}
|
242 |
+
```
|
243 |
+
|
244 |
+
<!-- Posts per topic -->
|
245 |
+
|
246 |
+
```js
|
247 |
+
function answersPerTopicChart(data, { width }) {
|
248 |
+
return Plot.plot({
|
249 |
+
title: "Answers per topic",
|
250 |
+
width,
|
251 |
+
height: 300,
|
252 |
+
marginTop: 0,
|
253 |
+
marginLeft: 150,
|
254 |
+
x: { grid: true, label: "Proportion (%)", percent: true },
|
255 |
+
y: { label: "Answers", reverse: true },
|
256 |
+
marks: [
|
257 |
+
Plot.rectX(
|
258 |
+
data,
|
259 |
+
Plot.binY(
|
260 |
+
{ x: "proportion" },
|
261 |
+
{
|
262 |
+
y: {
|
263 |
+
value: (d) => d.posts.length - 1,
|
264 |
+
thresholds: d3.range(-0.5, 10.5),
|
265 |
+
},
|
266 |
+
fill: (d) => (d.posts.length === 1 ? "#AAA" : "#DDD"),
|
267 |
+
tip: true,
|
268 |
+
}
|
269 |
+
)
|
270 |
+
),
|
271 |
+
Plot.ruleX([0]),
|
272 |
+
],
|
273 |
+
});
|
274 |
+
}
|
275 |
+
```
|
276 |
+
|
277 |
+
<div class="grid grid-cols-2">
|
278 |
+
<div class="card">
|
279 |
+
${resize((width) => categoriesChart(categories, {width}))}
|
280 |
+
</div>
|
281 |
+
<div class="card">
|
282 |
+
${resize((width) => answersPerTopicChart(topics, {width}))}
|
283 |
+
</div>
|
284 |
+
</div>
|
285 |
+
|
286 |
+
## Users
|
287 |
+
|
288 |
+
<!-- Top users -->
|
289 |
+
|
290 |
+
```js
|
291 |
+
function topUsersChart(data, { width }) {
|
292 |
+
return Plot.plot({
|
293 |
+
title: "Top posters",
|
294 |
+
width,
|
295 |
+
height: 300,
|
296 |
+
marginTop: 0,
|
297 |
+
marginLeft: 150,
|
298 |
+
x: { grid: true, label: "Posts" },
|
299 |
+
y: { label: null },
|
300 |
+
marks: [
|
301 |
+
Plot.barX(data, {
|
302 |
+
x: "posts",
|
303 |
+
y: "username",
|
304 |
+
fill: color.posts,
|
305 |
+
tip: true,
|
306 |
+
sort: { y: "-x" },
|
307 |
+
}),
|
308 |
+
Plot.ruleX([0]),
|
309 |
+
],
|
310 |
+
});
|
311 |
+
}
|
312 |
+
|
313 |
+
function topAcceptedUsersChart(data, { width }) {
|
314 |
+
return Plot.plot({
|
315 |
+
title: "Users with most accepted answers",
|
316 |
+
width,
|
317 |
+
height: 300,
|
318 |
+
marginTop: 0,
|
319 |
+
marginLeft: 150,
|
320 |
+
x: { grid: true, label: "Posts" },
|
321 |
+
y: { label: null },
|
322 |
+
marks: [
|
323 |
+
Plot.barX(data, {
|
324 |
+
x: "posts",
|
325 |
+
y: "username",
|
326 |
+
fill: color.accepted,
|
327 |
+
tip: true,
|
328 |
+
sort: { y: "-x" },
|
329 |
+
}),
|
330 |
+
Plot.ruleX([0]),
|
331 |
+
],
|
332 |
+
});
|
333 |
+
}
|
334 |
+
|
335 |
+
function topAcceptedUsersPerYearChart(data, { width }) {
|
336 |
+
return Plot.plot({
|
337 |
+
title: "User with most accepted answers per year",
|
338 |
+
width,
|
339 |
+
height: 300,
|
340 |
+
marginTop: 0,
|
341 |
+
marginLeft: 50,
|
342 |
+
marginRight: 50,
|
343 |
+
x: { grid: false, label: "Year" },
|
344 |
+
y: { grid: false, ticks: false, label: null, domain: [4, 0] },
|
345 |
+
color: { domain: [1, 2, 3], range: ["#FFD700", "#C0C0C0", "#CD7F32"] },
|
346 |
+
marks: [
|
347 |
+
Plot.image(data, {
|
348 |
+
x: (d) => new Date(d.year + "-01-01"),
|
349 |
+
y: "rank",
|
350 |
+
src: "src",
|
351 |
+
tip: true,
|
352 |
+
r: 20,
|
353 |
+
preserveAspectRatio: "xMidYMin slice",
|
354 |
+
title: (d) => `${d.username} - rank ${d.rank} (${d.year})`,
|
355 |
+
}),
|
356 |
+
Plot.dot(data, {
|
357 |
+
x: (d) => new Date(d.year + "-01-01"),
|
358 |
+
y: "rank",
|
359 |
+
r: 20,
|
360 |
+
stroke: "rank",
|
361 |
+
strokeWidth: 2,
|
362 |
+
}),
|
363 |
+
Plot.ruleY([4]),
|
364 |
+
],
|
365 |
+
});
|
366 |
+
}
|
367 |
+
```
|
368 |
+
|
369 |
+
<div class="grid grid-cols-2">
|
370 |
+
<div class="card">
|
371 |
+
${resize((width) => topUsersChart(tenTopUsers, {width}))}
|
372 |
+
</div>
|
373 |
+
<!--
|
374 |
+
<div class="card">
|
375 |
+
${resize((width) => topAcceptedUsersChart(tenTopAcceptedUsers, {width}))}
|
376 |
+
</div>
|
377 |
+
-->
|
378 |
+
<div class="card">
|
379 |
+
${resize((width) => topAcceptedUsersPerYearChart(topAcceptedUsersPerYear, {width}))}
|
380 |
+
</div>
|
381 |
+
</div>
|
382 |
+
|
383 |
+
Data: ${url} activity from ${d3.min(posts, d => d.created_at).getFullYear()} to ${d3.max(posts, d => d.created_at).getFullYear()} downloaded using the [Discourse API](https://docs.discourse.org/).
|
app/src/observable.png
ADDED
![]() |