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.0d4e9b14.js"> | |
| <link rel="modulepreload" href="./_observablehq/runtime.3f7f73d9.js"> | |
| <link rel="modulepreload" href="./_observablehq/stdlib.2229c972.js"> | |
| <link rel="modulepreload" href="./_node/d3-array@3.2.4/index.f89e3560.js"> | |
| <link rel="modulepreload" href="./_npm/d3-dsv@3.0.1/407f7a1f.js"> | |
| <link rel="modulepreload" href="./_npm/@observablehq/plot@0.6.16/e828d8c8.js"> | |
| <link rel="modulepreload" href="./_node/internmap@2.0.3/index.4106013c.js"> | |
| <link rel="modulepreload" href="./_npm/d3@7.9.0/7055d4c5.js"> | |
| <link rel="modulepreload" href="./_npm/isoformat@0.2.1/c68fbd73.js"> | |
| <link rel="modulepreload" href="./_npm/interval-tree-1d@1.0.4/a62ae5ce.js"> | |
| <link rel="modulepreload" href="./_npm/d3-array@3.2.4/e95f898e.js"> | |
| <link rel="modulepreload" href="./_npm/d3-axis@3.0.0/d44feff9.js"> | |
| <link rel="modulepreload" href="./_npm/d3-brush@3.0.0/5830b12a.js"> | |
| <link rel="modulepreload" href="./_npm/d3-chord@3.0.1/84d7b8e9.js"> | |
| <link rel="modulepreload" href="./_npm/d3-color@3.1.0/2c0cdfa2.js"> | |
| <link rel="modulepreload" href="./_npm/d3-contour@4.0.2/626bedc4.js"> | |
| <link rel="modulepreload" href="./_npm/d3-delaunay@6.0.4/00c41b5d.js"> | |
| <link rel="modulepreload" href="./_npm/d3-dispatch@3.0.1/b5f7cdc6.js"> | |
| <link rel="modulepreload" href="./_npm/d3-drag@3.0.0/b22c5864.js"> | |
| <link rel="modulepreload" href="./_npm/d3-ease@3.0.1/6f15f633.js"> | |
| <link rel="modulepreload" href="./_npm/d3-fetch@3.0.1/ef1ec490.js"> | |
| <link rel="modulepreload" href="./_npm/d3-force@3.0.0/5e1ff060.js"> | |
| <link rel="modulepreload" href="./_npm/d3-format@3.1.0/5851d7ef.js"> | |
| <link rel="modulepreload" href="./_npm/d3-geo@3.1.1/dcd02767.js"> | |
| <link rel="modulepreload" href="./_npm/d3-hierarchy@3.1.2/f1db2593.js"> | |
| <link rel="modulepreload" href="./_npm/d3-interpolate@3.0.1/034b7bcb.js"> | |
| <link rel="modulepreload" href="./_npm/d3-path@3.1.0/4bb53638.js"> | |
| <link rel="modulepreload" href="./_npm/d3-polygon@3.0.1/bbafde58.js"> | |
| <link rel="modulepreload" href="./_npm/d3-quadtree@3.0.1/aa5b35a8.js"> | |
| <link rel="modulepreload" href="./_npm/d3-random@3.0.1/32c7fec2.js"> | |
| <link rel="modulepreload" href="./_npm/d3-scale@4.0.2/567840a0.js"> | |
| <link rel="modulepreload" href="./_npm/d3-scale-chromatic@3.1.0/cf9b720b.js"> | |
| <link rel="modulepreload" href="./_npm/d3-selection@3.0.0/5dcd62f4.js"> | |
| <link rel="modulepreload" href="./_npm/d3-shape@3.2.0/f8e03c56.js"> | |
| <link rel="modulepreload" href="./_npm/d3-time@3.1.0/5bc129e1.js"> | |
| <link rel="modulepreload" href="./_npm/d3-time-format@4.1.0/19c92b44.js"> | |
| <link rel="modulepreload" href="./_npm/d3-timer@3.0.1/f31b5398.js"> | |
| <link rel="modulepreload" href="./_npm/d3-transition@3.0.1/8debb4ba.js"> | |
| <link rel="modulepreload" href="./_npm/d3-zoom@3.0.0/4b0cc581.js"> | |
| <link rel="modulepreload" href="./_npm/binary-search-bounds@2.0.5/1ee6c50d.js"> | |
| <link rel="modulepreload" href="./_npm/internmap@2.0.3/5eed35fd.js"> | |
| <link rel="modulepreload" href="./_npm/delaunator@5.0.1/e67acb27.js"> | |
| <link rel="modulepreload" href="./_npm/robust-predicates@3.0.2/8ac9039b.js"> | |
| <link rel="icon" href="./_file/observable.1af93621.png" type="image/png" sizes="32x32"> | |
| <script type="module"> | |
| import {define} from "./_observablehq/client.0d4e9b14.js"; | |
| import {registerFile} from "./_observablehq/stdlib.2229c972.js"; | |
| registerFile("./data/categories.csv", {"name":"./data/categories.csv","mimeType":"text/csv","path":"./_file/data/categories.f3fa0523.csv","lastModified":1727270905445,"size":662}); | |
| registerFile("./data/posts.csv", {"name":"./data/posts.csv","mimeType":"text/csv","path":"./_file/data/posts.4e3f4ea9.csv","lastModified":1727270904895,"size":6725351}); | |
| registerFile("./data/setup.json", {"name":"./data/setup.json","mimeType":"application/json","path":"./_file/data/setup.f452f82e.json","lastModified":1727270007346,"size":53}); | |
| define({id: "49c958aa", inputs: ["FileAttachment"], outputs: ["d3","setup","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/d3-array@3.2.4/index.f89e3560.js"); | |
| const setup = await FileAttachment("./data/setup.json").json(); | |
| const url = setup.base_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,setup,url,posts,categoriesRaw,topics,users,topicsByCategory,categories,tenTopUsers,tenTopAcceptedUsers,NUM_USERS,topAcceptedUsersPerYear,intervals,interval,intervalLabel,color,years}; | |
| }}); | |
| 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", 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"><!--:49c958aa:--></div> | |
| <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> | |
| <p>Data: <observablehq-loading></observablehq-loading><!--:28e5ebab:--> activity from <observablehq-loading></observablehq-loading><!--:78a1e913:--> to <observablehq-loading></observablehq-loading><!--:93beff2d:--> downloaded using the <a href="https://docs.discourse.org/" target="_blank" rel="noopener noreferrer">Discourse API</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-25T13:14:01">Sep 25, 2024</a>.</div> | |
| </footer> | |
| </div> | |