<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>The Tree Journals: Dataset Plotter</title>
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
<script src="https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6.17"></script>
/* Basic vanilla style with light/dark mode */
@media (prefers-color-scheme: dark) {
background-color: var(--background);
color: var(--foreground);
/* Adaptive grid for variable checkboxes */
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
#checkboxContainer label {
#timePickerContainer label {
#timePickerContainer input {
/* Style for the aggregation select field */
#aggregationContainer label {
/* Style for collapsible chart titles */
details[open] summary.chart-title {
border-bottom: 1px solid var(--foreground);
summary.chart-title span.name {
summary.chart-title label {
summary.chart-title input.transformToggle {
<div id="fileInputContainer">
<h1>The Tree Journals: Dataset Plotter</h1>
<input accept=".csv" id="csvFileInput" type="file" />
<div id="chartContainer"></div>
// Global object to preserve transformation state for each variable.
document.getElementById("csvFileInput").addEventListener("change", handleFile, false);
// Helper: format a Date as "YYYY-MM-DD" for date inputs.
function formatDate(date) {
return date.toISOString().split("T")[0];
function handleFile(event) {
const file = event.target.files[0];
const reader = new FileReader();
reader.onload = function(e) {
const text = e.target.result;
const data = d3.csvParse(text);
// Clear previous content.
d3.select("#chartContainer").html("");
// Main container for selectors and charts.
const container = d3.select("#chartContainer")
.attr("id", "variableSelector");
container.append("h2").text("Select Variables:");
const checkboxContainer = container.append("div")
.attr("id", "checkboxContainer");
// Create checkboxes for all columns (except some defaults).
const excluded = ["latitude", "longitude", "lat", "lon", "x", "y", "id", "fid"];
data.columns.forEach(col => {
const lowerCol = col.toLowerCase();
const isExcluded = excluded.includes(lowerCol);
const label = checkboxContainer.append("label");
.attr("type", "checkbox")
.attr("name", "variable")
.property("checked", !isExcluded);
label.append("span").text(" " + col);
// Detect a time-related column.
const timeCandidates = data.columns.filter(c =>
["time", "date", "datetime", "timestamp", "timestamps"].includes(c.toLowerCase())
if (timeCandidates.length > 0) {
timeColumn = timeCandidates[0];
const times = data.map(d => new Date(d[timeColumn]));
const minTime = new Date(Math.min(...times));
const maxTime = new Date(Math.max(...times));
const timePickerContainer = container.append("div")
.attr("id", "timePickerContainer");
timePickerContainer.append("h2").text("Select Time Range:");
timePickerContainer.append("label")
.attr("for", "startDate")
timePickerContainer.append("input")
.attr("min", formatDate(minTime))
.attr("max", formatDate(maxTime))
.attr("value", formatDate(minTime));
timePickerContainer.append("label")
timePickerContainer.append("input")
.attr("min", formatDate(minTime))
.attr("max", formatDate(maxTime))
.attr("value", formatDate(maxTime));
// Add a select field to specify the aggregation period.
// (These options are used to infer the window length in points.)
const aggregationContainer = container.append("div")
.attr("id", "aggregationContainer");
aggregationContainer.append("label")
.attr("for", "aggregationPeriod")
.text("Aggregation Period: ");
aggregationContainer.append("select")
.attr("id", "aggregationPeriod")
<option value="daily">Daily</option>
<option value="weekly" selected>Weekly</option>
<option value="biweekly">Bi-Weekly</option>
<option value="monthly">Monthly</option>
// Listen for changes on the aggregation field.
d3.select("#aggregationPeriod").on("change", updateCharts);
// Container to hold the charts.
const chartsContainer = container.append("div")
.attr("id", "chartsContainer");
// Listen for changes on variable checkboxes and time inputs.
d3.selectAll("input[name='variable']").on("change", updateCharts);
d3.select("#startDate").on("change", updateCharts);
d3.select("#endDate").on("change", updateCharts);
function updateCharts() {
const selectedVars = Array.from(document.querySelectorAll("input[name='variable']:checked"))
.map(input => input.value)
.filter(v => v !== timeColumn);
const startDate = new Date(document.getElementById("startDate").value);
const endDate = new Date(document.getElementById("endDate").value);
filteredData = data.filter(d => {
const dtime = new Date(d[timeColumn]);
return dtime >= startDate && dtime <= endDate;
// Weekly key dates (using Monday as week start).
weeks = d3.timeWeek.range(startDate, endDate);
// Compute average gap (ms) between consecutive data points (if available).
if (timeColumn && filteredData.length > 1) {
const sortedData = filteredData.slice().sort((a, b) => new Date(a[timeColumn]) - new Date(b[timeColumn]));
for (let i = 1; i < sortedData.length; i++) {
totalGap += (new Date(sortedData[i][timeColumn]) - new Date(sortedData[i-1][timeColumn]));
avgGap = totalGap / (sortedData.length - 1);
// Determine aggregation period in milliseconds from the select field.
const aggOption = document.getElementById("aggregationPeriod").value;
if (aggOption === "daily") periodMs = 86400000;
else if (aggOption === "weekly") periodMs = 604800000;
else if (aggOption === "biweekly") periodMs = 604800000 * 2;
else if (aggOption === "monthly") periodMs = 2592000000; // Approximate 30 days
// Compute k (number of points in the desired aggregation period) based on avgGap.
k = Math.max(1, Math.round(periodMs / avgGap));
// Clear previous charts.
chartsContainer.html("");
selectedVars.forEach(variable => {
if (transformState[variable] === undefined) transformState[variable] = false;
const values = filteredData
const [minVal, maxVal] = d3.extent(values);
const marginVal = (maxVal - minVal) * 0.1;
const yDomain = [minVal - marginVal, maxVal + marginVal];
const xAccessor = timeColumn ? (d => new Date(d[timeColumn])) : (d => d.index);
const yAccessor = d => +d[variable];
// Create a collapsible container.
const details = chartsContainer.append("details").attr("open", true);
const summaryEl = details.append("summary")
.attr("class", "chart-title");
summaryEl.html(`<span class="name">${variable}</span>
<input type="checkbox" class="transformToggle" ${transformState[variable] ? "checked" : ""}>
summaryEl.select("input.transformToggle").on("change", function() {
transformState[variable] = this.checked;
renderChart(variable, details, filteredData, timeColumn, weeks, xAccessor, yAccessor, yDomain, k);
renderChart(variable, details, filteredData, timeColumn, weeks, xAccessor, yAccessor, yDomain, k);
// Render a chart for one variable.
function renderChart(variable, details, filteredData, timeColumn, weeks, xAccessor, yAccessor, yDomain, k) {
details.select("div.chart").remove();
const chartContainer = details.append("div").attr("class", "chart").node();
strokeDasharray: "0.75,2",
stroke: "var(--gridLine)"
(timeColumn && weeks.length) ? Plot.ruleX(weeks, {
stroke: "var(--foreground)",
stroke: "var(--foreground)",
const xText = timeColumn ? d3.timeFormat("%Y-%m-%d")(d.x) : d.x;
return `${xText}: ${d.y}`;
if (transformState[variable]) {
Plot.line(filteredData, {
curve: d3.curveCatmullRom.alpha(0.5)
Plot.line(filteredData, Plot.windowY({ k: k, reduce: "min" }, {
Plot.line(filteredData, Plot.windowY({ k: k, reduce: "max" }, {
Plot.line(filteredData, Plot.windowY({ k: k, reduce: "median" }, {
Plot.line(filteredData, {
curve: d3.curveCatmullRom.alpha(0.5)
const marks = commonMarks.concat(lineMarks);
const width = chartContainer.clientWidth || 800;
const chart = Plot.plot({
tickFormat: timeColumn ? d3.timeFormat("%Y-%m-%d") : undefined
chartContainer.appendChild(chart);
chart.style.width = "100%";