This article shows how to build a self-contained HTML dashboard from Brightspace data using R Markdown and Chart.js. The result is a single HTML file you can open in any browser, share with colleagues, or host on a web server – no R or Shiny required to view it.
R Markdown is ideal for this: R chunks compute and aggregate data, then knitr’s inline R expressions inject the results directly into Chart.js JavaScript blocks. knitr knits the whole thing into a single self-contained HTML file.
Create a file called dashboard.Rmd with this YAML front
matter. The params block lets you render the same template
for different date ranges.
Add a hidden R chunk that loads and aggregates data. This chunk runs
but produces no visible output (include=FALSE).
# This chunk uses include=FALSE in the actual Rmd
library(brightspaceR)
library(dplyr)
library(lubridate)
library(jsonlite)
# Helpers: convert R vectors to JS array literals
js_labels <- function(x) toJSON(as.character(x), auto_unbox = FALSE)
js_values <- function(x) toJSON(as.numeric(x), auto_unbox = FALSE)
# Fetch datasets
enrollments <- bs_get_dataset("User Enrollments")
roles <- bs_get_dataset("Role Details")
grades <- bs_get_dataset("Grade Results")
org_units <- bs_get_dataset("Org Units")
users <- bs_get_dataset("Users")
# Apply date filter from params
enrollments <- enrollments |>
filter(
as.Date(enrollment_date) >= params$from_date,
as.Date(enrollment_date) <= params$to_date
)
# KPIs
total_users <- format(nrow(users), big.mark = ",")
total_enrol <- format(nrow(enrollments), big.mark = ",")
n_courses <- format(
n_distinct(org_units$org_unit_id[org_units$type == "Course Offering"]),
big.mark = ","
)
avg_grade <- grades |>
filter(!is.na(points_numerator), points_numerator >= 0) |>
summarise(m = round(mean(points_numerator, na.rm = TRUE), 1)) |>
pull(m)
# Chart data
role_counts <- enrollments |>
bs_join_enrollments_roles(roles) |>
count(role_name, sort = TRUE) |>
head(8)
monthly_trend <- enrollments |>
mutate(month = floor_date(as.Date(enrollment_date), "month")) |>
count(month) |>
arrange(month)
grade_dist <- grades |>
filter(!is.na(points_numerator), points_numerator >= 0) |>
mutate(bracket = cut(points_numerator,
breaks = seq(0, 100, 10), include.lowest = TRUE, right = FALSE
)) |>
count(bracket) |>
filter(!is.na(bracket))
top_courses <- enrollments |>
bs_join_enrollments_orgunits(org_units) |>
filter(type == "Course Offering") |>
count(name, sort = TRUE) |>
head(10)Below the data chunk, add raw HTML for the dashboard layout. knitr
evaluates inline R expressions everywhere in the document – including
inside HTML tags and <script> blocks. The syntax is a
backtick, the letter r, a space, an R expression, and a
closing backtick.
The KPI cards use inline R to inject computed values. For example, writing the inline R syntax in the HTML outputs the formatted number:
<div class="kpis">
<div class="kpi">
<div class="value" style="color:#818cf8">INLINE_R: total_users</div>
<div class="label">Total Users</div>
</div>
<div class="kpi">
<div class="value" style="color:#38bdf8">INLINE_R: total_enrol</div>
<div class="label">Enrollments</div>
</div>
<!-- ... same pattern for n_courses, avg_grade -->
</div>Where INLINE_R: total_users represents knitr’s inline R
syntax: a backtick, the letter r, a space, the expression, and a closing
backtick. knitr replaces these with the evaluated value at render
time.
The chart grid contains <canvas> elements for
Chart.js:
<div class="grid">
<div class="card">
<h2>Enrollments by Role</h2>
<canvas id="roleChart"></canvas>
</div>
<div class="card">
<h2>Monthly Enrollment Trend</h2>
<canvas id="trendChart"></canvas>
</div>
<div class="card">
<h2>Grade Distribution</h2>
<canvas id="gradeChart"></canvas>
</div>
<div class="card">
<h2>Top 10 Courses</h2>
<canvas id="courseChart"></canvas>
</div>
</div>Load Chart.js from CDN, then initialise each chart. The key
technique: inline R expressions inside <script> tags
inject R data as JavaScript arrays.
# In the Rmd, this is raw HTML (not an R chunk).
# Inline R expressions like `r js_labels(...)` are replaced by knitr
# with the evaluated JSON output before the HTML is finalised.
#
# Example Chart.js initialisation:
#
# <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
# <script>
# new Chart("roleChart", {
# type: "doughnut",
# data: {
# labels: `r js_labels(role_counts$role_name)`,
# datasets: [{
# data: `r js_values(role_counts$n)`,
# backgroundColor: ["#38bdf8","#818cf8","#f59e0b","#34d399"]
# }]
# }
# });
# </script>When knitr processes the Rmd, the inline R expressions are replaced with their evaluated results:
Before knitr:
labels: INLINE_R: js_labels(role_counts$role_name)
After knitr:
labels: ["Student","Instructor","TA","Observer"]
The js_labels() and js_values() helpers use
jsonlite::toJSON() to produce valid JavaScript array
literals. This is safer than manual paste0() because
toJSON() handles quoting, escaping, and edge cases
automatically.
The same pattern applies to each chart: line charts for trends, bar
charts for distributions, horizontal bars for top courses. Each
new Chart() call references a <canvas>
id and uses inline R to inject labels and data arrays.
Add a <style> block at the top of the HTML section
for the dashboard layout:
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f0f2f5; }
.header { background: linear-gradient(135deg, #1a1a2e, #16213e);
color: white; padding: 2rem; }
.kpis { display: grid; grid-template-columns: repeat(4, 1fr);
gap: 1rem; padding: 1.5rem; max-width: 1200px; margin: 0 auto; }
.kpi { background: white; border-radius: 12px; padding: 1.2rem;
text-align: center; }
.kpi .value { font-size: 2rem; font-weight: 700; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(480px, 1fr));
gap: 1.5rem; padding: 0 1.5rem 1.5rem; max-width: 1200px; margin: 0 auto; }
.card { background: white; border-radius: 12px; padding: 1.5rem; }
.card h2 { font-size: 1rem; color: #475569; margin-bottom: 1rem; }
</style>Render different versions from the same template without editing the Rmd:
# This semester
rmarkdown::render("dashboard.Rmd",
params = list(from_date = as.Date("2026-01-01"), to_date = Sys.Date()),
output_file = "brightspaceR_output/dashboard_s1_2026.html"
)
# Last year
rmarkdown::render("dashboard.Rmd",
params = list(from_date = as.Date("2025-01-01"), to_date = as.Date("2025-12-31")),
output_file = "brightspaceR_output/dashboard_2025.html"
)This makes it straightforward to generate quarterly or per-semester reports from a single template.
The MCP server’s execute_r tool can use either
approach:
execute_r, then call
rmarkdown::render(). This produces the cleanest output and
supports params.paste0() and writeLines(). No Rmd dependency,
but harder to maintain for complex layouts.Both write to the output directory and can be opened with
browseURL().
| Chart.js | plotly | |
|---|---|---|
| Dependencies | None (CDN) | plotly R package + htmlwidgets |
| File size | ~80KB (CDN-loaded) | 3-5MB per file (self-contained) |
| Sharing | Single HTML, opens anywhere | Single HTML, but large |
| R Markdown | Works via inline R in <script> |
Works via plotly::ggplotly() |
| MCP compatible | Yes (plain HTML string) | No (htmlwidgets needs R session) |
| Chart types | Bar, line, doughnut, scatter, radar | Everything, including 3D |
For most LMS analytics – bar charts, line trends, doughnut breakdowns – Chart.js covers the use cases with zero R-side dependencies.