PMNsvg = {
const margin = { left: 20, right: 20, top: 20, bottom: 20 };
const mapDimensions = { width: 975, height: 610 };
const treeDimensions = {
width: 400,
height: mapDimensions.height
};
const sliderDimensions = {
width: treeDimensions.width + mapDimensions.width,
height: 150
};
let plotData = PMNdata;
const svg = d3
.create("svg")
.attr("id", "rootSVG")
.attr("width", mapDimensions.width + treeDimensions.width)
.attr("height", mapDimensions.height + 5 + sliderDimensions.height);
function createLegend(gElement, x, y) {
const legendWidth = 475;
const legendHeight = 80;
const legend = gElement
.append("g")
.attr("class", "legend")
.attr("transform", `translate(${x}, ${y})`); // Position the legend at (x, y)
// Create a white background rectangle for the legend
legend
.append("rect")
.attr("width", legendWidth)
.attr("height", legendHeight)
.attr("fill", "white")
.attr("stroke", "#666")
.attr("rx", 2.5) // Rounded corners
.attr("ry", 2.5);
legend
.append("circle")
.attr("cx", 20)
.attr("cy", 20)
.attr("r", 6)
.attr("stroke", "#444")
.attr("stroke-width", 1.5)
.attr("fill", "#1f1");
legend
.append("circle")
.attr("cx", 20)
.attr("cy", 55)
.attr("r", 6)
.attr("stroke", "#444")
.attr("stroke-width", 1.5)
.attr("fill", "red");
// Add text to the legend
legend
.append("text")
.attr("x", 40)
.attr("y", 27) // Position the text a bit below the top of the rectangle
.style("font-size", "17px")
.style("font-family", "sans-serif")
.style("fill", "black")
.html("Sample site is present for selected date range and taxa.");
// Add text to the legend
legend
.append("text")
.attr("x", 40)
.attr("y", 61) // Position the text a bit below the top of the rectangle
.style("font-size", "17px")
.style("font-family", "sans-serif")
.style("fill", "black")
.html("Sample site is present AND contains elevated samples.");
return legend;
}
/////////////////////////////////////////////////////////////////////////////////////
///////////////////////////// TOOLTIPS /////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////
function createTooltip(gElement, x, y, text) {
// Tooltip dimensions
const tooltipWidth = text.length * 8.75;
const tooltipHeight = 50;
// Create the tooltip container within the specified <g> element
const tooltip = gElement
.append("g")
.attr("class", "tooltip")
.attr("transform", `translate(${x}, ${y})`); // Position the tooltip at (x, y)
// Create a white background rectangle for the tooltip
tooltip
.append("rect")
.attr("width", tooltipWidth)
.attr("height", tooltipHeight)
.attr("fill", "#dfd")
.attr("stroke", "#666")
.attr("rx", 5) // Rounded corners
.attr("ry", 5);
// Add text to the tooltip
tooltip
.append("text")
.attr("x", 14)
.attr("y", 30) // Position the text a bit below the top of the rectangle
.style("font-size", "17px")
.style("font-family", "sans-serif")
.style("fill", "black")
.html(text);
// Create the close button (X)
const closeButton = tooltip.append("g").attr("class", "closeButton");
closeButton
.append("rect")
.attr("width", 24)
.attr("height", 24)
.attr("x", tooltipWidth - 37)
.attr("y", 13)
.attr("rx", 5) // Rounded corners
.attr("ry", 5)
.attr("stroke", "#666")
.attr("fill", "#fff")
.on("mouseover", function (d) {
d3.select(this).style("cursor", "pointer");
})
.on("mouseout", function (d) {
d3.select(this).style("cursor", "default");
})
.on("click", function (d) {
// Remove the tooltip when the close button is clicked
tooltip.remove();
});
closeButton
.append("text")
.attr("x", tooltipWidth - 31)
.attr("y", 31.5)
.attr("rx", 5)
.style("cursor", "pointer")
.style("font-size", "18px")
.style("font-family", "sans-serif")
.style("fill", "#444")
.text("X")
.on("click", function () {
// Remove the tooltip when the close button is clicked
tooltip.remove();
});
return tooltip;
}
/////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////// MAP ////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////
// Create graphics object to hold map.
const gMap = svg
.append("g")
.attr("id", "gMap")
.call(d3.zoom().scaleExtent([1, 10]).on("zoom", zoomed));
// Create background rectangle.
gMap
.append("rect")
.attr("id", "mapBg")
.attr("width", width + 176)
.attr("height", mapDimensions.height)
.attr("fill", "#6af");
// Draw USA map.
gMap
.append("path")
.attr("fill", "#ffd")
.attr("stroke", "#111")
.attr("stroke-opacity", 0.333)
.attr("d", path(topojson.feature(us, us.objects.nation)));
// Draw USA state borders.
gMap
.append("path")
.attr("fill", "none")
.attr("stroke", "#111")
.attr("stroke-opacity", 0.333)
.attr("stroke-linejoin", "round")
.attr("stroke-linecap", "round")
.attr("d", path(topojson.mesh(us, us.objects.states, (a, b) => a !== b)));
gMap.attr("transform", `translate(${treeDimensions.width}, 0)`);
const projection = d3
.geoAlbers()
.scale(1300)
.translate([mapDimensions.width / 2, mapDimensions.height / 2]);
const color = d3.scaleOrdinal().domain([false, true]).range(["#1f1", "red"]);
/////////////////////////////////////////////////////////////////////////////////////
///////////////////////////// SITE PANES ////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////
const site_pane_width = 400;
const site_pane_header_height = 25;
const site_pane_window_height = 300;
const site_pane_x_dist = 10;
const site_pane_y_dist = -10;
function createSitePaneHeader(d) {
// Create the site pane container within the gMap element.
let loc = projection([d.longitude, d.latitude]);
const site_pane_header = gMap
.append("g")
.attr(
"id",
"site-pane-header-" + d.sample_site.replace(/\.|\s+/g, "").toLowerCase()
)
.attr("transform", `translate(${loc.join(",")})`);
let xLoc = 0;
if (loc[0] + site_pane_width + site_pane_x_dist + 25 < 975) {
xLoc = site_pane_x_dist;
} else {
xLoc = -site_pane_width - site_pane_x_dist;
}
let yLoc = 0;
if (loc[1] < 85) {
yLoc = 85;
} else if (
loc[1] +
site_pane_y_dist +
site_pane_header_height +
site_pane_window_height +
25 <
610
) {
yLoc = site_pane_y_dist;
} else {
yLoc =
610 - site_pane_header_height - site_pane_window_height - 25 - loc[1];
}
site_pane_header
.append("rect")
.attr("width", site_pane_width)
.attr("height", site_pane_header_height)
.attr("x", xLoc)
.attr("y", yLoc)
.attr("stroke", "#666")
.attr("fill", "#eee");
site_pane_header
.append("text")
.attr("font-family", "sans-serif")
.attr("font-weight", "bold")
.attr("text-anchor", "middle")
.attr("x", xLoc + site_pane_width / 2)
.attr("y", yLoc + site_pane_header_height - 7)
.text(d.sample_site);
}
function removeSitePaneHeader(d) {
gMap
.selectAll(
"#site-pane-header-" +
d.sample_site.replace(/\.|\s+/g, "").toLowerCase()
)
.remove();
}
function createSitePane(d) {
// Create the site pane container within the gMap element.
let loc = projection([d.longitude, d.latitude]);
let xLoc = 0;
if (loc[0] + site_pane_width + site_pane_x_dist + 25 < 975) {
xLoc = site_pane_x_dist;
} else {
xLoc = -site_pane_width - site_pane_x_dist;
}
let yLoc = 0;
if (loc[1] < 85) {
yLoc = 85;
} else if (
loc[1] +
site_pane_y_dist +
site_pane_header_height +
site_pane_window_height +
25 <
610
) {
yLoc = site_pane_y_dist;
} else {
yLoc =
610 - site_pane_header_height - site_pane_window_height - 25 - loc[1];
}
const site_pane = gMap
.append("g")
.attr(
"id",
"site-pane-" + d.sample_site.replace(/\.|\s+/g, "").toLowerCase()
)
.attr(
"transform",
`translate(${projection([d.longitude, d.latitude]).join(",")})`
);
site_pane
.append("rect")
.attr("width", site_pane_width)
.attr("height", site_pane_header_height)
.attr("x", xLoc)
.attr("y", yLoc)
.attr("stroke", "#666")
.attr("fill", "#eee");
// Create the close button (X)
const closeButton = site_pane.append("g").attr("class", "closeButton");
closeButton
.append("rect")
.attr("width", 18)
.attr("height", 18)
.attr("x", xLoc + site_pane_width - 22.5)
.attr("y", yLoc + 3)
.attr("rx", 2) // Rounded corners
.attr("ry", 2)
.attr("stroke", "#666")
.attr("fill", "#fff")
.on("mouseover", function (d) {
d3.select(this).style("cursor", "pointer");
})
.on("mouseout", function (d) {
d3.select(this).style("cursor", "default");
})
.on("click", function (d) {
// Remove the tooltip when the close button is clicked
site_pane.remove();
});
closeButton
.append("text")
.attr("x", xLoc + site_pane_width - 19)
.attr("y", yLoc + 18)
.attr("rx", 5)
.style("cursor", "pointer")
.style("font-size", "16px")
.style("font-family", "sans-serif")
.style("fill", "#444")
.text("X")
.on("click", function () {
// Remove the tooltip when the close button is clicked
site_pane.remove();
});
site_pane
.append("text")
.attr("font-family", "sans-serif")
.attr("font-weight", "bold")
.attr("text-anchor", "middle")
.attr("x", xLoc + site_pane_width / 2)
.attr("y", yLoc + site_pane_header_height - 7)
.text(d.sample_site);
const site_pane_window = site_pane
.append("g")
.attr(
"transform",
`translate(${xLoc}, ${yLoc + site_pane_header_height})`
);
site_pane_window
.append("rect")
.attr("width", site_pane_width)
.attr("height", site_pane_window_height)
.attr("stroke", "#666")
.attr("fill", "white");
function filterDataByDateRangeAndSite(PMNdata, dateRange, sampleSite) {
// Step 1: Parse the date range into JavaScript Date objects
const parseDate = d3.timeParse("%Y - %b"); // Parse the date in 'YYYY - MMM' format
// Parse the start and end dates from the dateRange array
const startDateStr = dateRange[0];
const endDateStr = dateRange[1];
console.log("startDateStr: ", startDateStr);
console.log("endDateStr: ", endDateStr);
// Adjust the input date strings to include a day (since the input has only year and month)
const startDate = parseDate(startDateStr); // Add a '01' day to the month
const endDate = parseDate(endDateStr); // Add a '01' day to the month
console.log("startDate: ", startDate);
console.log("endDate: ", endDate);
console.log("PMNdata: ", PMNdata);
// Check if parsing was successful (i.e., startDate and endDate are valid Date objects)
if (!startDate || !endDate) {
console.error("Invalid date range provided.");
return [];
}
// Step 2: Filter the PMNdata by checking if the datetime of each record is within the date range
return PMNdata.filter((d) => {
// Convert the string datetime to a JavaScript Date object
let recordDate = new Date();
if (typeof d.datetime === "string") {
recordDate = d3.timeParse("%Y-%m-%d %H:%M:%S+00:00")(d.datetime);
} else if (d.datetime instanceof Date && !isNaN(d.datetime)) {
recordDate = d.datetime;
} else {
console.log("Something went wrong getting record datetime");
}
// Check if the record's datetime is within the range and if it matches the sample site
const isDateInRange =
recordDate && recordDate >= startDate && recordDate <= endDate;
const isSiteMatch = sampleSite ? d.sample_site === sampleSite : true; // If sampleSite is provided, match it; otherwise, ignore
return isDateInRange && isSiteMatch;
});
}
const fmt = d3.timeParse("%Y-%m-%d %H:%M:%S%Z");
console.log("d.sample_site: ", d.sample_site);
console.log("d.sample_site: ", d.sample_site);
let tempData = Array.from(PMNdata);
let filteredData = filterDataByDateRangeAndSite(
tempData,
[d3.select("#label-left").text(), d3.select("#label-right").text()],
d.sample_site
);
console.log("filteredData pre map: ", filteredData);
filteredData = filteredData.map((d) => ((d.time = fmt(d.datetime)), d));
console.log("filteredData post map: ", filteredData);
const x = d3
.scaleTime()
.domain(d3.extent(filteredData, (d) => d.time))
.range([35, site_pane_width - 25])
.nice();
const y = d3
.scaleLinear()
.domain(d3.extent(filteredData, (d) => d.water_temp))
.range([site_pane_window_height - 25, 25])
.nice();
console.log("x and y set up");
site_pane_window
.append("g")
.call(d3.axisBottom(x).ticks(4).tickFormat(d3.timeFormat("%Y - %b")))
.attr("transform", `translate(0, ${site_pane_window_height - 25})`);
//console.log("x axis set up");
site_pane_window
.append("g")
.call(d3.axisLeft(y))
.attr("transform", `translate(35, 0)`);
//console.log("y axis set up");
const line = d3
.line()
.x((d) => x(d.time))
.y((d) => y(d.water_temp));
filteredData.forEach((d) => {
const parsedDate = new Date(d.time);
if (isNaN(parsedDate)) {
console.log("Invalid date:", d.time);
}
});
site_pane_window
.selectAll(".line")
.data([filteredData])
.join("path")
.attr("class", "line")
.attr("d", line)
.style("fill", "none")
.style("stroke", "steelblue");
site_pane_window
.append("text")
.attr("y", 20)
.attr("x", site_pane_width / 2)
.attr("text-anchor", "middle")
.attr("font-family", "sans-serif")
.attr("font-size", 18)
.text("Water Temperature (C)");
if (filteredData.length === 0) {
site_pane_window
.append("text")
.attr("y", 140)
.attr("x", site_pane_width / 2)
.attr("text-anchor", "middle")
.attr("font-family", "sans-serif")
.attr("font-size", 16)
.attr("stroke", "red")
.text("No Water Temperature Data Available");
}
}
function deleteSitePane(d) {
gMap
.selectAll(
"#site-pane-" + d.sample_site.replace(/\.|\s+/g, "").toLowerCase()
)
.remove();
}
/////////////////////////////////////////////////////////////////////////////////////
//////////////////////// UPDATE MAP FUNCTION ///////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////
// Define function to update map.
function updateMap(range) {
// Remove old circles.
gMap.selectAll(".site-point").remove();
gMap.selectAll("g").remove();
//console.log("pre-filter range:", range);
//console.log("pre-filter data:", PMNdata);
let filteredSites = getUniqueSampleSites(plotData, range);
//console.log("post-filter data:", filteredSites);
const sampleSiteElements = gMap
.selectAll("g")
.data(filteredSites)
.join("g");
const sampleSitePoints = sampleSiteElements
.append("g")
.attr(
"transform",
({ longitude, latitude }) =>
`translate(${projection([longitude, latitude]).join(",")})`
);
sampleSitePoints
.append("circle")
.attr("class", "site-point")
.attr("r", 3)
.attr("stroke", "#555")
.attr("fill", (d) => color(d["hasElevatedAbundance"]))
.attr("fill-opacity", 0.9)
.on("mouseover", function (event, d) {
d3.select(this).style("cursor", "pointer");
createSitePaneHeader(d);
})
.on("mouseout", function (event, d) {
d3.select(this).style("cursor", "default");
removeSitePaneHeader(d);
})
.on("click", function (event, d) {
if (
d3
.select(
"#site-pane-" + d.sample_site.replace(/\.|\s+/g, "").toLowerCase()
)
.empty()
) {
createSitePane(d);
} else {
deleteSitePane(d);
}
});
createLegend(gMap, 250, 25);
}
/////////////////////////////////////////////////////////////////////////////////////
//////////////////////////////// TREE //////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////
// Tree chart creation logic:
function createTreeChart(data) {
const margin = { left: 25, right: 25, top: 55 };
const format = d3.format(",");
const nodeSize = 36;
const nodeSpacing = 30;
const root = d3.hierarchy(data).eachBefore(
(
(i) => (d) =>
(d.index = i++)
)(0)
);
const nodes = root.descendants();
const height = (nodes.length + 1) * nodeSize;
const columns = [
{
label: "# of Records",
value: (d) => d.value,
format,
x: treeDimensions.width - 2 * margin.right
},
{
label: "All Data",
x: 4
},
{
label: "Phylum",
x: 35
},
{
label: "Class",
x: 65
},
{
label: "Order",
x: 95
},
{
label: "Family",
x: 125
},
{
label: "Genus",
x: 155
},
{
label: "Species",
x: 185
}
];
const svg = d3
.create("svg")
.attr("width", treeDimensions.width)
.attr("height", height)
.attr("viewBox", [
-margin.left,
-margin.top,
treeDimensions.width,
height
])
.attr("style", "font: 10px sans-serif;")
.attr("transform", `translate(${0}, ${margin.top})`);
const gTree = d3
.create("svg")
.attr("id", "inner-gTree")
.attr("width", 975)
.attr("height", height)
.attr("transform", `translate(0, 0)`);
// Add a scrollWindow to hold the scrollable content (HTML div).
const scrollWindow = svg
.append("foreignObject")
.attr("id", "scrollWindow")
.attr("width", width)
.attr("height", treeDimensions.height)
.append("xhtml:div")
.style("width", "100%")
.style("height", "100%")
.style("overflow-y", "scroll") // Enable vertical scrolling.
.style("overflow-x", "hidden") // Optional: Disable horizontal scrolling if needed.
.style("transform", `translate(0, ${nodeSize})`);
const link = gTree
.append("g")
.attr("fill", "none")
.attr("stroke", "#789")
.selectAll()
.data(root.links())
.join("path")
.attr(
"d",
(d) => `
M${d.source.depth * nodeSpacing + 10},${d.source.index * nodeSize + 15}
V${d.target.index * nodeSize + 15}
h${nodeSpacing}
`
);
const node = gTree
.append("g")
.selectAll()
.data(nodes)
.join("g")
.attr("class", "node")
.attr("id", (d) => d.data.name.replace(/\.|\s+/g, "").toLowerCase())
.attr("transform", (d) => `translate(0,${d.index * nodeSize + 15})`);
/////////////////////////////////////////////////////////////////////////////////////
//////////////////////// TREE - Node Selection //////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////
let selectedNodes = new Set();
// Store selected species names
let selectedSpecies = new Set();
// Update the selection when a node is selected/deselected
function updateSelection(event, d) {
function updateNode(event, d, select) {
const nodeGroup = d3.select(
`#${d.data.name.replace(/\.|\s+/g, "").toLowerCase()}`
);
const circle = nodeGroup.select("#node-circle");
const indicatorLine = nodeGroup.select("#indicator-line");
if (selectedNodes.has(d) && !select) {
// Deselect the node
circle
.transition()
.duration(200)
.attr("r", 6)
.attr("stroke-opacity", 0.5)
.attr("fill", "#789");
indicatorLine
.transition()
.duration(200)
.attr("stroke-width", 6)
.attr("stroke", "#abc");
selectedNodes.delete(d); // Remove from selected nodes
// Remove the species from the selected species set
selectedSpecies.delete(d.data.name);
} else if (!selectedNodes.has(d) && select) {
// Select the node
circle
.transition()
.duration(200)
.attr("r", 6.5)
.attr("stroke-opacity", 1)
.attr("fill", "#1f1");
indicatorLine
.transition()
.duration(200)
.attr("stroke-width", 6.5)
.attr("stroke", "#181");
selectedNodes.add(d); // Add to selected nodes
// Add the species to the selected species set
console.log("d.data.name: ", d.data.name);
selectedSpecies.add(d.data.name);
}
}
const selectBool = !selectedNodes.has(d);
d3.selectAll(".node")
.filter(
(nodeData) =>
nodeData.index >= d.index && nodeData.ancestors().includes(d)
)
.each((nodeData) => updateNode(event, nodeData, selectBool));
// After updating the selection, filter the PMNdata based on the selected species
updatePlotData();
}
// Filter PMNdata based on whether any key in the node's data has a value in selectedSpecies
function updatePlotData() {
// Ensure selectedSpecies is populated
if (selectedSpecies.size > 0) {
// Filter PMNdata by checking if any key in the data object matches a selected species
const filteredData = PMNdata.filter((d) => {
// Iterate through the keys of the node's data object
for (const key in d) {
// Check if the value of any key matches any value in selectedSpecies
if (selectedSpecies.has(d[key])) {
return true; // If a match is found, include this data point
}
}
return false; // If no match is found, exclude this data point
});
// Update plotData with the filtered data
plotData = filteredData;
// Optional: If you want to log the new plotData or do something with it
//console.log("Updated plotData:", plotData);
updateMap([
d3.select("#label-left").text(),
d3.select("#label-right").text()
]);
// You can now use this filtered plotData to update your visualizations, e.g., map, charts, etc.
} else {
// If no species are selected, clear the plotData (optional)
plotData = [];
updateMap([
d3.select("#label-left").text(),
d3.select("#label-right").text()
]);
}
}
node
.append("line")
.attr("id", "indicator-line")
.attr("fill", "none")
.attr("stroke-width", 8)
.attr("opacity", 0.1)
.attr("stroke", "#abc")
.attr("x1", (d) => d.depth * nodeSpacing + 10)
.attr("y1", 0)
.attr("x2", treeDimensions.width - 100)
.attr("y2", 0)
.on("mouseover", function () {
d3.select(this).style("cursor", "pointer");
})
.on("click", function (event, d) {
updateSelection(event, d);
})
.on("mouseout", function () {
d3.select(this).style("cursor", "default");
});
node
.append("rect")
.attr("id", "node-bg")
.attr(
"transform",
(d) => `translate(${10 + d.depth * nodeSpacing}, ${-12.5})`
)
.attr("width", (d) => d.data.name.length * 7)
.attr("height", 25)
.attr("fill", "#fff")
.attr("opacity", 0.2)
.on("mouseover", function () {
d3.select(this).style("cursor", "pointer");
})
.on("click", function (event, d) {
updateSelection(event, d);
})
.on("mouseout", function () {
d3.select(this).style("cursor", "default");
});
node
.append("circle")
.attr("id", "node-circle")
.style("position", "absolute")
.style("z-index", 30)
.attr("cx", (d) => d.depth * nodeSpacing + 10)
.attr("r", 6)
.attr("opacity", 1)
.attr("fill", "#789")
.attr("stroke", "black")
.attr("stroke-opacity", 0.5)
.on("mouseover", function () {
d3.select(this).style("cursor", "pointer");
})
.on("click", function (event, d) {
updateSelection(event, d);
})
.on("mouseout", function () {
d3.select(this).style("cursor", "default");
});
node
.append("text")
.attr("dy", "0.28em")
.attr("x", (d) => d.depth * nodeSpacing + 22)
.style("color", "#black")
.style("user-select", "none")
.attr("font-size", 12)
.text((d) => d.data.name)
.on("mouseover", function () {
d3.select(this).style("cursor", "pointer");
})
.on("click", function (event, d) {
updateSelection(event, d);
})
.on("mouseout", function () {
d3.select(this).style("cursor", "default");
});
node
.append("title")
.text((d) =>
d
.ancestors()
.reverse()
.map((d) => d.data.name)
.join("/")
)
.attr("color", "#1e3");
for (const { label, value, format, x } of columns) {
if (value) {
svg
.append("text")
.attr("dy", "0.32em")
.attr("y", -15)
.attr("x", x)
.attr("text-anchor", "end")
.attr("font-size", 12)
.attr("font-weight", "bold")
.text(label);
node
.append("text")
.attr("dy", "0.32em")
.attr("x", treeDimensions.width - 2 * margin.right)
.attr("text-anchor", "end")
.attr("font-size", 12)
.data(root.copy().sum(value).descendants())
.text((d) => format(d.value, d));
} else {
svg
.append("text")
.attr("dy", "0.32em")
.attr("y", -5)
.attr("x", x)
.attr("font-size", 12)
.attr("font-weight", "bold")
.attr("transform", `rotate(-55, ${x}, ${-10})`)
.text(label);
}
}
scrollWindow.node().appendChild(gTree.node());
return svg.node();
}
// Create graphics object to hold tree.
//console.log("mapHeight:");
//console.log(mapHeight);
const gTree = svg
.append("g")
.attr("id", "gTree")
.attr("width", treeDimensions.width)
.attr("height", treeDimensions.height);
// Create background rectangle.
gTree
.append("rect")
.attr("id", "tree-bg")
.attr("width", treeDimensions.width)
.attr("height", treeDimensions.height)
.attr("fill", "white");
// Create tree object and append to tree graphics object.
const tree = createTreeChart(tree_data);
// Assuming `tree` is the content (e.g., a chart) that needs to be scrollable.
gTree.node().appendChild(tree);
const spacer = svg
.append("g")
.attr("id", "spacer")
.attr("transform", `translate(0, ${treeDimensions.height})`)
.attr("width", treeDimensions.width + mapDimensions.width)
.attr("height", "5")
.append("rect")
.attr("width", treeDimensions.width + mapDimensions.width)
.attr("height", "5")
.attr("fill", "#black");
/////////////////////////////////////////////////////////////////////////////////////
//////////////////////////////// SLIDER /////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////
function histogramSlider(histogram, customOptions) {
const defaultOptions = {
w: sliderDimensions.width,
h: sliderDimensions.height,
margin: {
top: 32,
bottom: 32,
left: 24,
right: 24
},
bucketSize: 1,
defaultRange: [0, 100],
format: d3.format(".3s")
};
const [min, max] = d3.extent(Object.values(histogram).map((d) => +d));
const range = [min, max + 1];
//console.log("pre-sort PMNhist");
//console.log(PMNhist);
// Extract the year-month keys and sort them
// Sort the object by the date keys
const yearMonths = Object.entries(PMNhist)
.sort((a, b) => {
// Convert the date string to a Date object for comparison
const dateA = new Date(a[0].split(" - ").reverse().join(" ")); // Reverses "YYYY - MMM" to "MMM YYYY"
const dateB = new Date(b[0].split(" - ").reverse().join(" "));
return dateA - dateB; // Sort by date
})
.reduce((acc, [key, value]) => {
// Rebuild the object with sorted entries
acc[key] = value;
return acc;
}, {});
//console.log("post-sort yearMonths");
//console.log(yearMonths);
const yearMonthKeys = Object.keys(yearMonths);
// Set width and height of svg
const { w, h, margin, defaultRange, bucketSize, format } = {
...defaultOptions,
...customOptions
};
const iwidth = w - margin.left - margin.right;
const iheight = h - margin.top - margin.bottom;
//console.log("yearMonthKeys before x definition.");
//console.log(yearMonthKeys);
// Create x scale for year-month keys (ordinal scale)
const x = d3
.scaleBand()
.domain(yearMonthKeys) // The year-month combinations on the x-axis
.range([0, iwidth]); // Display space
// Create y scale based on histogram values (frequency counts)
const y = d3
.scaleLinear()
.domain([0, d3.max(Object.values(histogram))])
.range([iheight, 0]); // Invert the y-axis (higher values are at the top)
// Create svg and translated g group
var svg = d3.select(DOM.svg(w, h));
const g = svg
.append("g")
.attr("id", "histSlider")
.attr("transform", `translate(${margin.left}, ${margin.top})`);
// Draw histogram bars
//console.log("yearMonthKeys:");
//console.log(typeof yearMonthKeys);
//console.log(Object.keys(yearMonthKeys));
//console.log(x(Object.keys(yearMonthKeys)[20]));
//console.log("");
// Create background rectangle.
g.append("rect")
.attr("id", "sliderbg")
.attr("width", iwidth)
.attr("height", iheight)
.attr("fill", "white")
.attr("transform", `translate(0, 0)`);
// Draw histogram bars
g.append("g")
.selectAll("rect")
.data(yearMonthKeys)
.enter()
.append("rect")
.attr("x", (d) => x(d))
.attr("y", (d) => y(histogram[d] || 0))
.attr("width", x.bandwidth()) // Bar width based on scale
.attr("height", (d) => iheight - y(histogram[d] || 0))
.style("fill", "#118");
// Draw background lines
g.append("g")
.selectAll("line")
.data(yearMonthKeys)
.enter()
.append("line")
.attr("x1", (d) => x(d) + x.bandwidth()) // Adjust line to middle of each bar
.attr("x2", (d) => x(d) + x.bandwidth())
.attr("y1", 0)
.attr("y2", iheight)
.style("stroke", "#ccc");
var leftVal = yearMonthKeys[0];
var rightVal = yearMonthKeys[yearMonthKeys.length - 2];
//console.log("yearMonthKeys:", yearMonthKeys[0]);
var labelL = g
.append("text")
.attr("id", "label-left")
.attr("font-family", "sans-serif")
.attr("x", 0)
.attr("y", -10) // Move label 10px below the histogram
.attr("text-anchor", "start")
.text(leftVal);
var labelR = g
.append("text")
.attr("id", "label-right")
.attr("font-family", "sans-serif")
.attr("x", 10)
.attr("y", iheight + 20) // Move label 10px below the histogram
.attr("text-anchor", "end")
.text(rightVal);
// Variable to hold the dynamically updated value of label-right
let rightLabelValue = rightVal;
// Define brush
var brush = d3
.brushX()
.extent([
[0, 0],
[iwidth, iheight]
])
.on("brush", function (event) {
// Here we use the event passed to the handler directly
var s = event.selection;
const leftIndex = Math.floor(s[0] / x.bandwidth());
const rightIndex = Math.floor(s[1] / x.bandwidth() - 1);
if (leftIndex > rightIndex) {
const temp = leftIndex;
leftIndex = rightIndex;
rightIndex = temp;
}
//console.log("getting left and right val...");
//console.log("yearMonthKeys:", yearMonthKeys);
leftVal = yearMonthKeys[leftIndex];
rightVal = yearMonthKeys[rightIndex];
//console.log("leftVal:", leftVal);
//console.log("righttVal:", rightVal);
//console.log("labelL set to:", leftVal);
//console.log("labelR set to:", rightVal);
labelL.attr("x", s[0]).text(leftVal);
labelR.attr("x", s[1]).text(rightVal);
if (s[0] > 81) {
labelL.attr("text-anchor", "end");
} else {
labelL.attr("text-anchor", "start");
}
if (s[1] < 843) {
labelR.attr("text-anchor", "start");
} else {
labelR.attr("text-anchor", "end");
}
// Update the `rightLabelValue` variable to reflect the new value of label-right
rightLabelValue = rightVal; // Update dynamically
handle
.attr("display", null)
.attr(
"transform",
(d, i) => "translate(" + [s[i], -iheight / 4] + ")"
);
//console.log("updating from slider creation");
//console.log("range:", [leftVal, rightVal]);
updateMap([leftVal, rightVal]);
})
.on("end", function (event) {
// Here we also use the event passed to the handler directly
if (!event.sourceEvent) return; // Only transition after brush ends
var s = event.selection;
if (!s) return;
const leftIndex = Math.floor(s[0] / x.bandwidth());
const rightIndex = Math.floor(s[1] / x.bandwidth());
var lastLeftIndex = leftIndex;
var lastRightIndex = rightIndex;
if (leftIndex === lastLeftIndex && rightIndex === lastRightIndex)
return;
leftVal = yearMonthKeys[leftIndex];
rightVal = yearMonthKeys[rightIndex];
// Update the `rightLabelValue` variable to reflect the new value of label-right
rightLabelValue = rightVal; // Update dynamically
d3.select(this)
.transition()
.call(event.target.move, [x(leftVal), x(rightVal)]);
lastLeftIndex = leftIndex;
lastRightIndex = rightIndex;
//console.log("updating from slider creation");
updateMap([leftVal, rightVal]);
});
// Append brush to g
var gBrush = g.append("g").attr("class", "brush").call(brush);
// Add brush handles (from https://bl.ocks.org/Fil/2d43867ba1f36a05459c7113c7f6f98a)
var brushResizePath = function (d) {
var e = +(d.type == "e"),
x = e ? 1 : -1,
y = iheight / 2;
return (
"M" +
0.5 * x +
"," +
y +
"A6,6 0 0 " +
e +
" " +
6.5 * x +
"," +
(y + 6) +
"V" +
(2 * y - 6) +
"A6,6 0 0 " +
e +
" " +
0.5 * x +
"," +
2 * y +
"Z" +
"M" +
2.5 * x +
"," +
(y + 8) +
"V" +
(2 * y - 8) +
"M" +
4.5 * x +
"," +
(y + 8) +
"V" +
(2 * y - 8)
);
};
var handle = gBrush
.selectAll(".handle--custom")
.data([{ type: "w" }, { type: "e" }])
.enter()
.append("path")
.attr("class", "handle--custom")
.attr("stroke", "#666")
.attr("fill", "#eee")
.attr("cursor", "ew-resize")
.attr("d", brushResizePath);
// Initialize brush range
gBrush.call(
brush.move,
[0, iwidth] // Default to full range
);
svg.append("style").text(style);
return svg.node();
}
// Create graphics object to hold slider.
const gSlider = svg
.append("g")
.attr("id", "gSlider")
.attr("width", sliderDimensions.width)
.attr("height", sliderDimensions.height)
.attr("transform", `translate(0, ${mapDimensions.height + 5})`);
// Create background rectangle.
gSlider
.append("rect")
.attr("id", "sliderbg")
.attr("width", sliderDimensions.width)
.attr("height", sliderDimensions.height)
.attr("fill", "#ddd")
.attr("transform", `translate(0, 0)`);
// Create slider object and append to slider graphics object.
function createYearMonthHistogram(dataset) {
// Array of month abbreviations (indexed from 0 to 11, corresponding to January to December)
const monthAbbreviations = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec"
];
// Initialize an empty object to store the histogram
const histogram = {};
// Loop through each record in the dataset
dataset.forEach((record) => {
// Extract the datetime string (e.g., "2005-10-22 12:30:56+00:00")
const datetime = record.datetime;
// Extract year and month from the datetime (e.g., "2005-10" from "2005-10-22 12:30:56+00:00")
const year = datetime.toString().substring(0, 4); // Year is the first 4 characters (e.g., "2005")
const monthIndex = parseInt(datetime.toString().substring(5, 7)) - 1; // Month index is 0-based (January is 0, February is 1, etc.)
// Create the year-month key with the month abbreviation
const yearMonth = `${year} - ${monthAbbreviations[monthIndex]}`;
// If this year-month combination is already in the histogram, increment the count
if (histogram[yearMonth]) {
histogram[yearMonth]++;
} else {
// Otherwise, initialize the count for this year-month combination
histogram[yearMonth] = 1;
}
});
return histogram;
}
let PMNhist = createYearMonthHistogram(plotData);
//console.log("PMNhist:");
//console.log(PMNhist);
const slider = histogramSlider(PMNhist, {
w: treeDimensions.width + mapDimensions.width,
h: sliderDimensions.height
});
//console.log("done");
gSlider.node().appendChild(slider);
// Step 2: Add event listener to update map on slider change
d3.select(slider).on("input", function (event) {
//console.log("updating from listener");
updateMap();
});
/////////////////////////////////////////////////////////////////////////////////////
//////////////////////////////// START /////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////
let range = ["2000 - Jan", "2024 - Dec"];
//console.log("initial range update");
//console.log(range);
updateMap(range);
function zoomed(event) {
gMap.attr("transform", event.transform);
}
createTooltip(svg, 50, 100, "Select tree nodes to filter by taxa");
createTooltip(svg, 50, 666, "Drag slider handles to filter by date");
createTooltip(svg, 800, 400, "Hover / click on sample sites for more data");
return svg.node();
}