Public
Edited
Dec 10
Insert cell
Insert cell
// Import required libraries
d3 = require("d3@7")

Insert cell
// Load data
data = {
const csv = await FileAttachment("phytoplankton_viz_data.csv").csv();
return csv;
}

Insert cell
//// Display sample of the data to examine structure
viewof sampleData = Inputs.table(data.slice(0, 5))
Insert cell
// Get column names using Object.keys on the first row
columnNames = {
return Object.keys(data[0]);
}
Insert cell
// Define abundance mapping
abundanceMapping = {
return {
Rare: 1,
Present: 2,
PresentRhizosolenia: 2,
Common: 3,
Elevated: 4,
"Elevated.": 4,
Abundant: 5,
Bloom: 6,
rotifers: 0
};
}
Insert cell
// Process the data into the format we need
processedData = {
// Get all species columns (excluding non-species columns)
const speciesColumns = Object.keys(data[0]).filter(
(col) =>
col !== "year" &&
col !== "sampl_site" &&
col !== "latitude" &&
col !== "longitude"
);

// Transform data into long format
const longFormat = [];

data.forEach((row) => {
speciesColumns.forEach((species) => {
if (row[species] > 0) {
// Only include non-zero values
longFormat.push({
year: +row.year,
sampl_site: row.sampl_site,
latitude: +row.latitude,
longitude: +row.longitude,
spec_name: species,
abundance_value: +row[species]
});
}
});
});

console.log("Processed data length:", longFormat.length);
console.log("Sample processed row:", longFormat[0]);

return longFormat;
}
Insert cell
processedData
Type Table, then Shift-Enter. Ctrl-space for more options.

Insert cell
// Filtered for Elevated instances
elevatedData = {
// An abundance_value of 4 or higher indicates elevated levels
const elevated = processedData.filter(d => d.abundance_value >= 4)
console.log("Original data length:", processedData.length)
console.log("Filtered data length:", elevated.length)
console.log("Sample elevated row:", elevated[0])
return elevated
}
Insert cell
// Display sample of filtered data
viewof elevatedSample = Inputs.table(elevatedData.slice(0, 20))
Insert cell
// Summary of elevated instances by site
elevatedSummary = {
return d3.rollup(
elevatedData,
(v) => ({
count: v.length,
species: Array.from(new Set(v.map((d) => d.spec_name))),
years: Array.from(new Set(v.map((d) => d.year)))
}),
(d) => d.sampl_site
);
}
Insert cell
// Get dataset shape and basic info
dataShape = {
const numRows = elevatedData.length;
const numColumns = Object.keys(elevatedData[0]).length;
const columns = Object.keys(elevatedData[0]);

// Create a summary object similar to pandas info()
const summary = {
shape: [numRows, numColumns],
columns: columns,
columnTypes: {},
nonNullCounts: {}
};

// Get type and non-null counts for each column
columns.forEach((col) => {
const values = elevatedData.map((d) => d[col]);
summary.columnTypes[col] = typeof values[0];
summary.nonNullCounts[col] = values.filter((v) => v != null).length;
});

console.log(`Dataset Shape: ${numRows} rows × ${numColumns} columns`);
console.log("\nColumns:");
columns.forEach((col) => {
const type = summary.columnTypes[col];
const nonNull = summary.nonNullCounts[col];
const nullCount = numRows - nonNull;
console.log(`${col}: ${type} (${nonNull} non-null, ${nullCount} null)`);
});

return summary;
}
Insert cell
// Display shape info in a more structured table
viewof dataInfo = Inputs.table([
{
"Total Rows": dataShape.shape[0],
"Total Columns": dataShape.shape[1],
"Column Names": dataShape.columns.join(", ")
}
])
Insert cell
// Get unique species counts per year for elevated instances
elevatedSpeciesPerYear = {
const summary = d3.rollup(elevatedData,
v => new Set(v.map(d => d.spec_name)).size,
d => d.year
)
// Convert Map to array of objects for easier display
return Array.from(summary, ([year, count]) => ({
year: year,
uniqueElevatedSpecies: count
})).sort((a, b) => a.year - b.year)
}
Insert cell
// Display elevated species counts table
viewof elevatedSpeciesTable = Inputs.table(elevatedSpeciesPerYear)
Insert cell
// Get unique locations counts per year for elevated instances
elevatedLocationsPerYear = {
const summary = d3.rollup(elevatedData,
v => new Set(v.map(d => d.sampl_site)).size,
d => d.year
)
// Convert Map to array of objects for easier display
return Array.from(summary, ([year, count]) => ({
year: year,
uniqueLocationsWithElevated: count
})).sort((a, b) => a.year - b.year)
}
Insert cell
// Display elevated locations counts table
viewof elevatedLocationsTable = Inputs.table(elevatedLocationsPerYear)
Insert cell
// Create the visualization function
function createElevatedChart(elevatedData) {
const width = 1600;
const height = 1000;
const margin = {
top: 80,
right: 300,
bottom: 250,
left: 80
};

// Font sizes object for easy management
const fontSize = {
title: "28px",
yearLabel: "26px",
axisLabel: "24px",
tickLabel: "16px",
legend: "20px",
siteLabel: "18px"
};

// Create the container
const container = d3
.create("div")
.style("width", "100%")
.style("max-width", "1600px")
.style("margin", "0 auto");

// Create SVG
const svg = container
.append("svg")
.attr("viewBox", [0, 0, width, height])
.style("max-width", "100%")
.style("height", "auto");

// Get unique years and species
const years = Array.from(new Set(elevatedData.map((d) => d.year))).sort();
const allSpecies = Array.from(new Set(elevatedData.map((d) => d.spec_name)));

// Create scales
const xScale = d3
.scaleBand()
.padding(0.5)
.range([margin.left, width - margin.right]);

const yScale = d3.scaleLinear().range([height - margin.bottom, margin.top]);

const colorScale = d3
.scaleOrdinal()
.domain(allSpecies)
.range(d3.schemeCategory10);

// Add title
svg
.append("text")
.attr("x", width / 2)
.attr("y", margin.top / 2)
.attr("text-anchor", "middle")
.style("font-size", fontSize.title)
.text("Elevated Phytoplankton Samples by Location");

// Create axes groups
const xAxis = svg
.append("g")
.attr("transform", `translate(0,${height - margin.bottom})`);

const yAxis = svg
.append("g")
.attr("transform", `translate(${margin.left},0)`);

// Add axis labels
svg
.append("text")
.attr("x", width / 2)
.attr("y", height - 10)
.attr("text-anchor", "middle")
.style("font-size", fontSize.axisLabel)
.text("Sampling Site");

svg
.append("text")
.attr("transform", "rotate(-90)")
.attr("x", -height / 2)
.attr("y", margin.left / 3)
.attr("text-anchor", "middle")
.style("font-size", fontSize.axisLabel)
.text("Count of Elevated Samples");

// Create year text
const yearText = svg
.append("text")
.attr("x", width / 2)
.attr("y", margin.top - 10)
.attr("text-anchor", "middle")
.style("font-size", fontSize.yearLabel);

// Create legend group
const legend = svg
.append("g")
.attr("class", "legend")
.attr(
"transform",
`translate(${width - margin.right + 20}, ${margin.top})`
);

// Update function
function update(selectedYear) {
const yearData = d3.group(
elevatedData.filter((d) => d.year === selectedYear),
(d) => d.sampl_site
);

const sites = Array.from(yearData.keys());

const activeSpecies = Array.from(
new Set(
elevatedData
.filter((d) => d.year === selectedYear)
.map((d) => d.spec_name)
)
);

const stackedData = d3
.stack()
.keys(activeSpecies)
.value((d, key) => {
const matches = d[1].filter((item) => item.spec_name === key);
return matches.length;
})(Array.from(yearData.entries()));

xScale.domain(sites);
yScale.domain([
0,
Math.ceil(d3.max(stackedData, (d) => d3.max(d, (d) => d[1])))
]);

// Update x-axis with rotated and wrapped text
xAxis
.call(d3.axisBottom(xScale))
.selectAll(".tick text")
.style("text-anchor", "end")
.attr("dx", "-.8em")
.attr("dy", ".15em")
.attr("transform", "rotate(-45)")
.style("font-size", fontSize.siteLabel)
.each(function (d) {
const text = d3.select(this);
const words = text
.text()
.split(/\s+|(?=-)/)
.filter((d) => d !== "-");
text.text(null);

let tspan = text.append("tspan").attr("x", 0).attr("dy", "0em");

let line = [];
let lineNumber = 0;
const maxWidth = 200;

for (let word of words) {
line.push(word);
tspan.text(line.join(" "));

if (tspan.node().getComputedTextLength() > maxWidth) {
line.pop();
tspan.text(line.join(" "));
line = [word];

tspan = text
.append("tspan")
.attr("x", 0)
.attr("dy", "1.2em")
.text(word);

lineNumber++;
}
}
});

// Update y-axis
yAxis
.call(
d3
.axisLeft(yScale)
.ticks(Math.ceil(yScale.domain()[1]))
.tickFormat(d3.format("d"))
)
.selectAll(".tick text")
.style("font-size", fontSize.tickLabel);

yearText.text(selectedYear);

// Update legend
legend.selectAll("*").remove();

activeSpecies.forEach((spec, i) => {
const legendRow = legend
.append("g")
.attr("transform", `translate(0, ${i * 40})`);

legendRow
.append("rect")
.attr("width", 12)
.attr("height", 12)
.attr("fill", colorScale(spec));

legendRow
.append("text")
.attr("x", 24)
.attr("y", 10)
.style("font-size", fontSize.legend)
.text(spec);
});

// Create tooltip div if it doesn't exist
const tooltip = d3
.select("body")
.selectAll(".tooltip")
.data([null])
.join("div")
.attr("class", "tooltip")
.style("position", "absolute")
.style("visibility", "hidden")
.style("background-color", "white")
.style("border", "solid")
.style("border-width", "1px")
.style("border-radius", "5px")
.style("padding", "10px")
.style("font-size", "12px")
.style("pointer-events", "none")
.style("box-shadow", "3px 3px 5px rgba(0, 0, 0, 0.2)");

// Update bars with tooltips
const series = svg
.selectAll(".series")
.data(stackedData)
.join("g")
.attr("class", "series")
.attr("fill", (d) => colorScale(d.key));

series
.selectAll("rect")
.data((d) => d)
.join("rect")
.attr("x", (d) => xScale(d.data[0]))
.attr("y", (d) => yScale(d[1]))
.attr("height", (d) => yScale(d[0]) - yScale(d[1]))
.attr("width", xScale.bandwidth())
.on("mouseover", function (event, d) {
const speciesName = d3.select(this.parentNode).datum().key;
const siteCount = d[1] - d[0];

tooltip.style("visibility", "visible").html(`
<strong>Location:</strong> ${d.data[0]}<br/>
<strong>Species:</strong> ${speciesName}<br/>
<strong>Elevated Samples:</strong> ${siteCount}
`);
})
.on("mousemove", function (event) {
tooltip
.style("top", event.pageY - 10 + "px")
.style("left", event.pageX + 10 + "px");
})
.on("mouseout", function () {
tooltip.style("visibility", "hidden");
});
}

// Create slider
const slider = container
.append("input")
.attr("type", "range")
.attr("min", d3.min(years))
.attr("max", d3.max(years))
.attr("value", d3.min(years))
.attr("step", 1)
.style("width", "80%")
.style("margin", "20px 10%")
.on("input", function () {
if (isPlaying) {
pause();
}
update(+this.value);
});

// Add playback controls
const playbackControls = container
.append("div")
.style("text-align", "center")
.style("margin", "10px 0");

// Add play button
const playButton = playbackControls
.append("button")
.text("Play")
.style("margin", "0 5px")
.style("padding", "5px 15px");

// Add speed control
const speedControl = playbackControls
.append("select")
.style("margin", "0 5px");

speedControl
.selectAll("option")
.data([
{ label: "Slow", value: 2000 },
{ label: "Medium", value: 1000 },
{ label: "Fast", value: 500 }
])
.enter()
.append("option")
.text((d) => d.label)
.attr("value", (d) => d.value);

// Animation variables
let interval;
let isPlaying = false;

// Play function
function play() {
if (!isPlaying) {
isPlaying = true;
playButton.text("Pause");

interval = setInterval(() => {
let currentYear = +slider.node().value;
let nextYear = currentYear + 1;

// Reset to first year if we reach the end
if (nextYear > d3.max(years)) {
nextYear = d3.min(years);
}

// Update slider and visualization
slider.node().value = nextYear;
update(nextYear);
}, +speedControl.node().value);
} else {
pause();
}
}

// Pause function
function pause() {
if (isPlaying) {
isPlaying = false;
playButton.text("Play");
clearInterval(interval);
}
}

// Add event listeners
playButton.on("click", play);
speedControl.on("change", function () {
if (isPlaying) {
clearInterval(interval);
play();
}
});

// Initial update
update(d3.min(years));

return container.node();
}
Insert cell
// Create and display the chart
elevated_chart = createElevatedChart(elevatedData)
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more