Spaces:
Running
Running
<meta charset="utf-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | |
<meta name="generator" content="Observable Framework v1.11.0"> | |
<title>Forum Dashboard | Observable Forum Dashboard</title> | |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&display=swap" crossorigin> | |
<link rel="preload" as="style" href="./_observablehq/theme-air,near-midnight,alt,wide.f6ca92af.css"> | |
<link rel="stylesheet" type="text/css" href="https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&display=swap" crossorigin> | |
<link rel="stylesheet" type="text/css" href="./_observablehq/theme-air,near-midnight,alt,wide.f6ca92af.css"> | |
<link rel="modulepreload" href="./_observablehq/client.4d3b1f74.js"> | |
<link rel="modulepreload" href="./_observablehq/runtime.c4c9c1af.js"> | |
<link rel="modulepreload" href="./_observablehq/stdlib.b89f0aee.js"> | |
<link rel="modulepreload" href="./_node/[email protected]/index.f89e3560.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/407f7a1f.js"> | |
<link rel="modulepreload" href="./_npm/@observablehq/[email protected]/e828d8c8.js"> | |
<link rel="modulepreload" href="./_node/[email protected]/index.4106013c.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/7055d4c5.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/c68fbd73.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/a62ae5ce.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/e95f898e.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/d44feff9.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/5830b12a.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/84d7b8e9.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/2c0cdfa2.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/626bedc4.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/00c41b5d.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/b5f7cdc6.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/b22c5864.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/6f15f633.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/ef1ec490.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/5e1ff060.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/5851d7ef.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/dcd02767.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/f1db2593.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/034b7bcb.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/4bb53638.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/bbafde58.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/aa5b35a8.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/32c7fec2.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/567840a0.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/cf9b720b.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/5dcd62f4.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/f8e03c56.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/5bc129e1.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/19c92b44.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/f31b5398.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/8debb4ba.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/4b0cc581.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/1ee6c50d.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/5eed35fd.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/e67acb27.js"> | |
<link rel="modulepreload" href="./_npm/[email protected]/8ac9039b.js"> | |
<link rel="icon" href="./_file/observable.1af93621.png" type="image/png" sizes="32x32"> | |
<script type="module"> | |
import {define} from "./_observablehq/client.4d3b1f74.js"; | |
import {registerFile} from "./_observablehq/stdlib.b89f0aee.js"; | |
registerFile("./data/categories.csv", {"name":"./data/categories.csv","mimeType":"text/csv","path":"./_file/data/categories.f3fa0523.csv","lastModified":1727299665259,"size":662}); | |
registerFile("./data/config.json", {"name":"./data/config.json","mimeType":"application/json","path":"./_file/data/config.05513390.json","lastModified":1727299086052,"size":50}); | |
registerFile("./data/posts.csv", {"name":"./data/posts.csv","mimeType":"text/csv","path":"./_file/data/posts.36b9d40b.csv","lastModified":1727299664899,"size":6728921}); | |
define({id: "e35ec5a1", inputs: ["FileAttachment"], outputs: ["d3","config","url","posts","categoriesRaw","topics","users","topicsByCategory","categories","tenTopUsers","tenTopAcceptedUsers","NUM_USERS","topAcceptedUsersPerYear","intervals","interval","intervalLabel","color","years"], body: async (FileAttachment) => { | |
const d3 = await import("./_node/[email protected]/index.f89e3560.js"); | |
const config = await FileAttachment("./data/config.json").json(); | |
const url = config.discourse_url; | |
const posts = await FileAttachment("./data/posts.csv").csv({ typed: true }); | |
const categoriesRaw = await FileAttachment("./data/categories.csv").csv({ | |
typed: true, | |
}); | |
const topics = [ | |
...d3 | |
.rollup( | |
posts, | |
(v) => ({ | |
topic_id: v[0].topic_id, | |
category_id: v[0].category_id, | |
posts: v, | |
users: new Set(v.map((d) => d.username)), | |
}), | |
(d) => d.topic_id | |
) | |
.values(), | |
]; | |
const users = d3.rollup( | |
posts, | |
(v) => ({ username: v[0].username, avatar_template: v[0].avatar_template }), | |
(d) => d.username | |
); | |
const topicsByCategory = d3.rollup( | |
topics, | |
(v) => v.length, | |
(d) => d.category_id | |
); | |
const categories = categoriesRaw.map((d) => ({ | |
...d, | |
topics: topicsByCategory.get(d.id) || 0, | |
})); | |
const tenTopUsers = d3 | |
.rollups( | |
posts, | |
(v) => v.length, | |
(d) => d.username | |
) | |
.sort((a, b) => d3.descending(a[1], b[1])) | |
.slice(0, 10) | |
.map((d) => ({ | |
username: d[0], | |
posts: d[1], | |
})); | |
const tenTopAcceptedUsers = d3 | |
.rollups( | |
posts.filter((d) => d.accepted_answer), | |
(v) => v.length, | |
(d) => d.username | |
) | |
.sort((a, b) => d3.descending(a[1], b[1])) | |
.slice(0, 10) | |
.map((d) => ({ | |
username: d[0], | |
posts: d[1], | |
})); | |
const NUM_USERS = 3; | |
const topAcceptedUsersPerYear = d3 | |
.rollups( | |
posts.filter((d) => d.accepted_answer), | |
(v) => v.length, | |
(d) => d.created_at.getFullYear(), | |
(d) => d.username | |
) | |
.flatMap(([year, users_stats]) => { | |
const top_usernames = users_stats | |
.sort(([_, posts_count_a], [__, posts_count_b]) => | |
d3.descending(posts_count_a, posts_count_b) | |
) | |
.slice(0, NUM_USERS) | |
.map(([username]) => username); | |
return top_usernames.map((username, i) => ({ | |
rank: i + 1, | |
year, | |
username, | |
src: url + users.get(username).avatar_template.replace("{size}", "400"), | |
})); | |
}); | |
const intervals = { month: "Month", year: "Year", day: "Day", week: "Week" }; | |
const interval = "month"; | |
const intervalLabel = intervals[interval]; | |
const color = { | |
users: "#e36209", | |
posts: "#3b5fc0", | |
accepted: "green", | |
}; | |
const years = d3.extent(posts, (d) => d.created_at.getFullYear()); | |
return {d3,config,url,posts,categoriesRaw,topics,users,topicsByCategory,categories,tenTopUsers,tenTopAcceptedUsers,NUM_USERS,topAcceptedUsersPerYear,intervals,interval,intervalLabel,color,years}; | |
}}); | |
define({id: "28e5ebab", mode: "inline", inputs: ["url","display"], body: async (url,display) => { | |
display(await( | |
url | |
)) | |
}}); | |
define({id: "8404f401", mode: "inline", inputs: ["years","display"], body: async (years,display) => { | |
display(await( | |
years[0] | |
)) | |
}}); | |
define({id: "18528b79", mode: "inline", inputs: ["years","display"], body: async (years,display) => { | |
display(await( | |
years[1] | |
)) | |
}}); | |
define({id: "ebe544d8", mode: "inline", inputs: ["topics","display"], body: async (topics,display) => { | |
display(await( | |
topics.length.toLocaleString("en-US") | |
)) | |
}}); | |
define({id: "a9ac2ae4", mode: "inline", inputs: ["posts","display"], body: async (posts,display) => { | |
display(await( | |
posts.length.toLocaleString("en-US") | |
)) | |
}}); | |
define({id: "9f3c82c4", mode: "inline", inputs: ["users","display"], body: async (users,display) => { | |
display(await( | |
users.size.toLocaleString("en-US") | |
)) | |
}}); | |
define({id: "3939618f", inputs: ["Plot","color"], outputs: ["postsMAU"], body: (Plot,color) => { | |
function postsMAU(data, { width } = {}) { | |
return Plot.plot({ | |
title: `Monthly active users`, | |
width, | |
height: 300, | |
y: { grid: true, label: `users` }, | |
// color: {...color, legend: true}, | |
marks: [ | |
Plot.lineY( | |
data, | |
Plot.binX( | |
{ y: "distinct" }, | |
{ | |
x: "created_at", | |
y: "username", | |
stroke: color.users, | |
interval: "month", | |
tip: true, | |
} | |
) | |
), | |
Plot.ruleY([0]), | |
], | |
}); | |
} | |
return {postsMAU}; | |
}}); | |
define({id: "694a2e8e", inputs: ["Plot","interval","color"], outputs: ["postsTimeline"], body: (Plot,interval,color) => { | |
function postsTimeline(data, { width } = {}) { | |
return Plot.plot({ | |
title: `Posts created every ${interval}`, | |
width, | |
height: 300, | |
y: { grid: true, label: "posts" }, | |
// color: {...color, legend: true}, | |
marks: [ | |
Plot.lineY( | |
data, | |
Plot.binX( | |
{ y: "count" }, | |
{ x: "created_at", stroke: color.posts, interval, tip: true } | |
) | |
), | |
Plot.ruleY([0]), | |
], | |
}); | |
} | |
return {postsTimeline}; | |
}}); | |
define({id: "df5d29c1", mode: "inline", inputs: ["resize","postsMAU","posts","display"], body: async (resize,postsMAU,posts,display) => { | |
display(await( | |
resize((width) => postsMAU(posts, {width})) | |
)) | |
}}); | |
define({id: "8db957c1", mode: "inline", inputs: ["resize","postsTimeline","posts","display"], body: async (resize,postsTimeline,posts,display) => { | |
display(await( | |
resize((width) => postsTimeline(posts, {width})) | |
)) | |
}}); | |
define({id: "35e85ade", inputs: ["Plot"], outputs: ["categoriesChart"], body: (Plot) => { | |
function categoriesChart(data, { width }) { | |
return Plot.plot({ | |
title: "Most active categories", | |
width, | |
height: 300, | |
marginTop: 0, | |
marginLeft: 150, | |
x: { grid: true, label: "Topics" }, | |
y: { label: null }, | |
marks: [ | |
Plot.barX(data, { | |
x: "topics", | |
y: "name", | |
fill: (d) => "#" + d.color, | |
tip: true, | |
sort: { y: "-x" }, | |
}), | |
Plot.ruleX([0]), | |
], | |
}); | |
} | |
return {categoriesChart}; | |
}}); | |
define({id: "4933adab", inputs: ["Plot","d3"], outputs: ["answersPerTopicChart"], body: (Plot,d3) => { | |
function answersPerTopicChart(data, { width }) { | |
return Plot.plot({ | |
title: "Answers per topic", | |
width, | |
height: 300, | |
marginTop: 0, | |
marginLeft: 150, | |
x: { grid: true, label: "Proportion (%)", percent: true }, | |
y: { label: "Answers", reverse: true }, | |
marks: [ | |
Plot.rectX( | |
data, | |
Plot.binY( | |
{ x: "proportion" }, | |
{ | |
y: { | |
value: (d) => d.posts.length - 1, | |
thresholds: d3.range(-0.5, 10.5), | |
}, | |
fill: (d) => (d.posts.length === 1 ? "#AAA" : "#DDD"), | |
tip: true, | |
} | |
) | |
), | |
Plot.ruleX([0]), | |
], | |
}); | |
} | |
return {answersPerTopicChart}; | |
}}); | |
define({id: "e3ec0bba", mode: "inline", inputs: ["resize","categoriesChart","categories","display"], body: async (resize,categoriesChart,categories,display) => { | |
display(await( | |
resize((width) => categoriesChart(categories, {width})) | |
)) | |
}}); | |
define({id: "4e86c617", mode: "inline", inputs: ["resize","answersPerTopicChart","topics","display"], body: async (resize,answersPerTopicChart,topics,display) => { | |
display(await( | |
resize((width) => answersPerTopicChart(topics, {width})) | |
)) | |
}}); | |
define({id: "1e4bc9aa", inputs: ["Plot","color"], outputs: ["topUsersChart","topAcceptedUsersChart","topAcceptedUsersPerYearChart"], body: (Plot,color) => { | |
function topUsersChart(data, { width }) { | |
return Plot.plot({ | |
title: "Top posters", | |
width, | |
height: 300, | |
marginTop: 0, | |
marginLeft: 150, | |
x: { grid: true, label: "Posts" }, | |
y: { label: null }, | |
marks: [ | |
Plot.barX(data, { | |
x: "posts", | |
y: "username", | |
fill: color.posts, | |
tip: true, | |
sort: { y: "-x" }, | |
}), | |
Plot.ruleX([0]), | |
], | |
}); | |
} | |
function topAcceptedUsersChart(data, { width }) { | |
return Plot.plot({ | |
title: "Users with most accepted answers", | |
width, | |
height: 300, | |
marginTop: 0, | |
marginLeft: 150, | |
x: { grid: true, label: "Posts" }, | |
y: { label: null }, | |
marks: [ | |
Plot.barX(data, { | |
x: "posts", | |
y: "username", | |
fill: color.accepted, | |
tip: true, | |
sort: { y: "-x" }, | |
}), | |
Plot.ruleX([0]), | |
], | |
}); | |
} | |
function topAcceptedUsersPerYearChart(data, { width }) { | |
return Plot.plot({ | |
title: "User with most accepted answers per year", | |
width, | |
height: 300, | |
marginTop: 0, | |
marginLeft: 50, | |
marginRight: 50, | |
x: { grid: false, label: "Year" }, | |
y: { grid: false, ticks: false, label: null, domain: [4, 0] }, | |
color: { domain: [1, 2, 3], range: ["#FFD700", "#C0C0C0", "#CD7F32"] }, | |
marks: [ | |
Plot.image(data, { | |
x: (d) => new Date(d.year + "-01-01"), | |
y: "rank", | |
src: "src", | |
tip: true, | |
r: 20, | |
preserveAspectRatio: "xMidYMin slice", | |
title: (d) => `${d.username} - rank ${d.rank} (${d.year})`, | |
}), | |
Plot.dot(data, { | |
x: (d) => new Date(d.year + "-01-01"), | |
y: "rank", | |
r: 20, | |
stroke: "rank", | |
strokeWidth: 2, | |
}), | |
Plot.ruleY([4]), | |
], | |
}); | |
} | |
return {topUsersChart,topAcceptedUsersChart,topAcceptedUsersPerYearChart}; | |
}}); | |
define({id: "0fa544ed", mode: "inline", inputs: ["resize","topUsersChart","tenTopUsers","display"], body: async (resize,topUsersChart,tenTopUsers,display) => { | |
display(await( | |
resize((width) => topUsersChart(tenTopUsers, {width})) | |
)) | |
}}); | |
define({id: "04e33544", mode: "inline", inputs: ["resize","topAcceptedUsersPerYearChart","topAcceptedUsersPerYear","display"], body: async (resize,topAcceptedUsersPerYearChart,topAcceptedUsersPerYear,display) => { | |
display(await( | |
resize((width) => topAcceptedUsersPerYearChart(topAcceptedUsersPerYear, {width})) | |
)) | |
}}); | |
define({id: "28e5ebab-1", mode: "inline", inputs: ["url","display"], body: async (url,display) => { | |
display(await( | |
url | |
)) | |
}}); | |
define({id: "78a1e913", mode: "inline", inputs: ["d3","posts","display"], body: async (d3,posts,display) => { | |
display(await( | |
d3.min(posts, d => d.created_at).getFullYear() | |
)) | |
}}); | |
define({id: "93beff2d", mode: "inline", inputs: ["d3","posts","display"], body: async (d3,posts,display) => { | |
display(await( | |
d3.max(posts, d => d.created_at).getFullYear() | |
)) | |
}}); | |
</script> | |
<div id="observablehq-center"> | |
<main id="observablehq-main" class="observablehq"> | |
<h1 id="forum-dashboard" tabindex="-1"><a class="observablehq-header-anchor" href="#forum-dashboard">Forum Dashboard</a></h1> | |
<!-- Load and transform the data --> | |
<div class="observablehq observablehq--block"><!--:e35ec5a1:--></div> | |
<p>Statistics for the <a href="./$%7Burl%7D"><observablehq-loading></observablehq-loading><!--:28e5ebab:--></a> forum.</p> | |
<h2 id="trends-over-time" tabindex="-1"><a class="observablehq-header-anchor" href="#trends-over-time">Trends over time</a></h2> | |
<!-- Cards with big numbers --> | |
<div class="grid grid-cols-4"> | |
<div class="card"> | |
<h2>Years</h2> | |
<span class="big"><observablehq-loading></observablehq-loading><!--:8404f401:-->—<observablehq-loading></observablehq-loading><!--:18528b79:--></span> | |
</div> | |
<div class="card"> | |
<h2>Topics</h2> | |
<span class="big"><observablehq-loading></observablehq-loading><!--:ebe544d8:--></span> | |
<p>Topics are the forum's questions, or threads.</p> | |
</div> | |
<div class="card"> | |
<h2>Posts</h2> | |
<span class="big"><observablehq-loading></observablehq-loading><!--:a9ac2ae4:--></span> | |
<p>The posts are comments in a thread, ie the answers to a question. The topics are not included.</p> | |
</div> | |
<!-- <div class="card"> | |
<h2>Posts per topic</h2> | |
<span class="big">${(posts.length / topics.size).toLocaleString("en-US", { | |
minimumFractionDigits: 2, | |
maximumFractionDigits: 2, | |
})}</span> | |
</div> | |
<div class="card"> | |
<h2>Users per topic</h2> | |
<span class="big">${(d3.sum(topics, d => d[1].users.size) / topics.size).toLocaleString("en-US", { | |
minimumFractionDigits: 2, | |
maximumFractionDigits: 2, | |
})}</span> | |
</div> --> | |
<!-- <div class="card"> | |
<h2>Categories</h2> | |
<span class="big">${categories.length.toLocaleString("en-US")}</span> | |
</div> --> | |
<div class="card"> | |
<h2>Users</h2> | |
<span class="big"><observablehq-loading></observablehq-loading><!--:9f3c82c4:--></span> | |
</div> | |
</div> | |
<!-- Plot of monthly active users --> | |
<div class="observablehq observablehq--block"><!--:3939618f:--></div> | |
<!-- Plot of posts history --> | |
<div class="observablehq observablehq--block"><!--:694a2e8e:--></div> | |
<div class="grid grid-cols-2"> | |
<div class="card"> | |
<observablehq-loading></observablehq-loading><!--:df5d29c1:--> | |
</div> | |
<div class="card"> | |
<observablehq-loading></observablehq-loading><!--:8db957c1:--> | |
</div> | |
</div> | |
<h2 id="topics" tabindex="-1"><a class="observablehq-header-anchor" href="#topics">Topics</a></h2> | |
<!-- Plot of topics per category --> | |
<div class="observablehq observablehq--block"><!--:35e85ade:--></div> | |
<!-- Posts per topic --> | |
<div class="observablehq observablehq--block"><!--:4933adab:--></div> | |
<div class="grid grid-cols-2"> | |
<div class="card"> | |
<observablehq-loading></observablehq-loading><!--:e3ec0bba:--> | |
</div> | |
<div class="card"> | |
<observablehq-loading></observablehq-loading><!--:4e86c617:--> | |
</div> | |
</div> | |
<h2 id="users" tabindex="-1"><a class="observablehq-header-anchor" href="#users">Users</a></h2> | |
<!-- Top users --> | |
<div class="observablehq observablehq--block"><!--:1e4bc9aa:--></div> | |
<div class="grid grid-cols-2"> | |
<div class="card"> | |
<observablehq-loading></observablehq-loading><!--:0fa544ed:--> | |
</div> | |
<!-- | |
<div class="card"> | |
${resize((width) => topAcceptedUsersChart(tenTopAcceptedUsers, {width}))} | |
</div> | |
--> | |
<div class="card"> | |
<observablehq-loading></observablehq-loading><!--:04e33544:--> | |
</div> | |
</div> | |
<h2 id="data-and-code" tabindex="-1"><a class="observablehq-header-anchor" href="#data-and-code">Data and code</a></h2> | |
<p>The data of <a href="./$%7Burl%7D"><observablehq-loading></observablehq-loading><!--:28e5ebab-1:--></a> from <observablehq-loading></observablehq-loading><!--:78a1e913:--> to <observablehq-loading></observablehq-loading><!--:93beff2d:--> was downloaded using the <a href="https://docs.discourse.org/" target="_blank" rel="noopener noreferrer">Discourse API</a>.</p> | |
<p>The code is available on <a href="https://github.com/severo/discourse-dashboard" target="_blank" rel="noopener noreferrer">GitHub</a>.</p> | |
</main> | |
<footer id="observablehq-footer"> | |
<div>Built with <a href="https://observablehq.com/" target="_blank" rel="noopener noreferrer">Observable</a> on <a title="2024-09-25T21:18:02">Sep 25, 2024</a>.</div> | |
</footer> | |
</div> | |