Public
Edited
Apr 27, 2023
3 forks
21 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
phz_min = tree.hardiness_min
Insert cell
phz_max = tree.hardiness_max
Insert cell
Insert cell
Insert cell
map = () => {
const wrapper = d3.create("div")
.style("width", `${width}px`)
.style("height", `${height}px`)
.style("position", "relative");

wrapper.append("style").html(css);

// Raster layer
const canvas = wrapper.append("canvas")
.style("position", "absolute");
canvas.node().width = width;
canvas.node().height = height;
const context = canvas.node().getContext("2d");
path.context(context);
points
.forEach((d, i) => {
const { lon, lat } = d;
const color_prop = color(d);
let c = color_prop ? colors[color_prop] : "#fff";
const p = projection([lon, lat]);

// This seems to line up
const x = p[0] - cellwidth / 2;
const y = p[1] - cellheight * 2;
context.fillStyle = c;
context.strokeStyle = c;
context.fillRect(x, y, cellwidth, cellheight);
context.strokeRect(x, y, cellwidth, cellheight);
});

// Fast clip by overlaying Canada, Mexico and the ocean
context.strokeStyle = "white";
context.fillStyle = "white";
canmexGeo.features
.forEach((feature, index) => {
context.beginPath();
path(feature);
context.fill();
context.stroke();
});
oceanGeo.features
.forEach((feature, index) => {
context.beginPath();
path(feature);
context.fill();
context.stroke();
});

// Draw the boundaries on the raster
context.beginPath();
path(usGeoInner);
context.strokeStyle = "#e9e9e9"
context.stroke();
context.beginPath();
path(usGeoOuter);
context.strokeStyle = "#d5d5d5"
context.stroke();

// Vector layor
const svg = wrapper.append("svg")
.style("position", "absolute")
.attr("width", width)
.attr("height", height);

// Add legend explaining range outline
const legendPolygonWidth = 12;
const legendPolygonHeight = 16;
const legendPoints = [
[0, 0],
[0, legendPolygonHeight / 2],
[2, legendPolygonHeight / 2],
[2, legendPolygonHeight],
[legendPolygonWidth, legendPolygonHeight],
[legendPolygonWidth, 0],
[0, 0]
];
const legendG = svg.append("g")
.attr("transform", `translate(${[width * (rangeKeyLeft(width)), height * (rangeKeyTop(width))]})`);

legendG.append("polygon")
.attr("fill", "none")
.attr("stroke", "black")
.attr("points", legendPoints);

legendG.append("text")
.attr("font-family", franklinLight)
.attr("x", legendPolygonWidth + 4)
.attr("y", legendPolygonHeight * 0.5 + 5)
.text("Typical range");

// Highlight typical growing range with subtle inner glow
// See also https://observablehq.com/@veltman/inner-glow
path.context(null)
const defs = svg.append("defs")
defs
.append("filter")
.attr("id", "blur")
.append("feGaussianBlur")
.attr("color-interpolation-filters", "sRGB") // for better results in Safari
.attr("stdDeviation", 2);

defs.append("clipPath")
.datum(range)
.attr("id", "clip-path")
.append("path")
.attr("d", path);
svg.append("path")
.datum(range)
.attr("fill", "none")
.attr("stroke", "black")
.attr("stroke-linejoin", "round")
.attr("stroke-opacity", 0.8)
.attr("stroke-width", 1)
.attr("d", path)

svg.append("path")
.datum(range)
.attr("d", path)
.attr("clip-path", "url(#clip-path)")
.attr("filter", "url(#blur)")
.style("stroke", "black")
.style("opacity", 0.8)
.style("fill", "none");
const citiesG = svg.selectAll(".city")
.data(cities)
.join("g")
.attr("class", d => `city ${d.pos} ${toSlugCase(d.name)}`)
.attr("transform", d => `translate(${projection([d.lon, d.lat])})`);

citiesG.append("circle")
.attr("r", 3);

citiesG.append("text")
.attr("class", "bg bg-0")
.text(d => d.name);

citiesG.append("text")
.attr("class", "bg bg-1")
.text(d => d.name);

citiesG.append("text")
.attr("class", "fg")
.text(d => d.name);
return wrapper.node();
}
Insert cell
legend = () => html`
<div style='font-family: ${franklinLight}; width: ${width}px'>
<svg style='display: table; margin: 0 auto; overflow: visible;' width="300" height="79">
<g>
<text x="60" y="11" text-anchor="middle"><tspan>Not suitable</tspan><tspan x="60" dy="17">to grow</tspan></text>
<text x="150" y="11" text-anchor="middle"><tspan x="150" dy="17">No change</tspan></text>
<text x="240" y="11" text-anchor="middle"><tspan>New areas</tspan><tspan x="240" dy="17">to grow</tspan></text>
</g>
<g transform="translate(0, 35)">
<rect width="60" height="12" fill="${colors.gone_2069}"></rect>
<rect x="60" width="60" height="12" fill="${colors.gone_2099}"></rect>
<rect x="120" width="60" height="12" fill="${colors.current}"></rect>
<rect x="180" width="60" height="12" fill="${colors.new_2099}"></rect>
<rect x="240" width="60" height="12" fill="${colors.new_2069}"></rect>
</g>
<g transform="translate(0, 61)">
<text x="30" text-anchor="middle" font-size="14"><tspan>Mid-</tspan><tspan x="30" dy="14">century</tspan></text>
<text x="90" text-anchor="middle" font-size="14"><tspan>Late</tspan><tspan x="90" dy="14">century</tspan></text>
<text x="210" text-anchor="middle" font-size="14"><tspan>Late</tspan><tspan x="210" dy="14">century</tspan></text>
<text x="270" text-anchor="middle" font-size="14"><tspan>Mid-</tspan><tspan x="270" dy="14">century</tspan></text>
</g>
</svg>
</div>
`
Insert cell
Insert cell
css = `
.city circle {
fill: none;
stroke: black;
}

.city.ne text {
transform: translate(4px, -4px);
}
.city.se text {
transform: translate(4px, 13px);
}
.city.e text {
transform: translate(6px, 4.5px);
}

.city.boston {
display: ${width <= 480 ? "none" : "block"};
}
.city.boston text {
text-anchor: ${width <= 900 ? "end" : "start"};
transform: translate(${width <= 900 ? "-4px" : "4px"}, -4px);
}

.city.chicago text {
text-anchor: ${width <= 620 ? "end" : "start"};
transform: translate(${width <= 620 ? "-4px" : "4px"}, -4px);
}

.city.minneapolis {
display: ${width <= 480 ? "none" : "block"};
}

.city.new-york text {
text-anchor: ${width <= 660 ? "end" : "start"};
transform: translate(${width <= 600 ? "-4px" : "4px"}, -4px);
}

.city.philadelphia {
display: ${width <= 480 ? "none" : "block"};
}
.city.philadelphia text {
text-anchor: ${width <= 768 ? "end" : "start"};
transform: translate(${width <= 768 ? "-6px" : "6px"}, 4.5px);
}

.city.seattle text {
transform: translate(4px, ${width <= 400 ? "13px" : "-4px"});
}

.city.st-louis {
display: ${width <= 480 ? "none" : "block"};
}

.city.washington-dc text {
text-anchor: ${width <= 400 ? "end" : width <= 768 ? "middle" : "start"};
transform: ${width <= 400 ? "translate(-4px, 13px)" : width <= 768 ? "translate(0px, 16px)" : "translate(4px, 13px)"};
}

.city text {
font-size: ${width <= 480 ? "14px" : "16px"};
font-family: ${franklinLight};
}

.city text.bg {
fill: white;
stroke: white;
}

.city text.bg-0 {
stroke-width: 4px;
stroke-opacity: 0.2;
}

.city text.bg-1 {
stroke-width: 2px;
stroke-opacity: 0.5;
}
`
Insert cell
colors = ({
gone_2069: "#b43b13",
gone_2099: "#e79174",
current: "#b2d6a0",
new_2069: "#1366b3",
new_2099: "#84a4d7"
})
Insert cell
color = d => {
const {phz_2009, phz_2039, phz_2069, phz_2099} = d;
if (!phz_2009 && !phz_2039 && !phz_2069 && !phz_2099) return null;
const is = phz => phz >= phz_min && phz <= phz_max;
const is_2009 = is(phz_2009);
const is_2039 = is(phz_2039);
const is_2069 = is(phz_2069);
const is_2099 = is(phz_2099);
if ((!is_2069 || !is_2099) && is_2039) {
if (!is_2069) return "gone_2069";
return "gone_2099";
}
if ((is_2069 || is_2099) && !is_2039){
if (is_2069) return "new_2069";
return "new_2099";
}
if (is_2039) return "current";

return null;
}
Insert cell
Insert cell
height = width * 0.631
Insert cell
cellwidth = width / lons.length
Insert cell
cellheight = height / lats.length
Insert cell
rangeKeyLeft = ww => ww <= 400 ? 0.08 : ww <= 768 ? 0.1 : 0.72;
Insert cell
rangeKeyTop = ww => ww <= 768 ? 0.9 : 0.1;
Insert cell
Insert cell
projection = d3.geoAlbers()
.fitSize([width, height], usGeoOuter)
Insert cell
path = d3.geoPath(projection)
Insert cell
// Outline for inner glow tree range
range = topojson.merge(usTopo, usTopo.objects.ne_50m_admin_1_states_provinces_lakes.geometries.filter(d => tree.states.includes(d.properties.postal)))
Insert cell
// Filtered from Natural Earth (Admin 1 – States, provinces): https://www.naturalearthdata.com/downloads/50m-cultural-vectors/
usTopo = FileAttachment("usTopo.json").json()
Insert cell
usGeoOuter = topojson.mesh(usTopo, usTopo.objects.ne_50m_admin_1_states_provinces_lakes, (a, b) => a === b)
Insert cell
usGeoInner = topojson.mesh(usTopo, usTopo.objects.ne_50m_admin_1_states_provinces_lakes, (a, b) => a !== b)
Insert cell
usGeo = topojson.feature(usTopo, usTopo.objects.ne_50m_admin_1_states_provinces_lakes)
Insert cell
cities = FileAttachment("cities.json").json()
Insert cell
canmexTopo = FileAttachment("ne_50m_admin_0_countries.json").json()
Insert cell
// Filtered from Natural Earth (Admin 0 – Countries): https://www.naturalearthdata.com/downloads/50m-cultural-vectors/
canmexGeo = topojson.feature(canmexTopo, canmexTopo.objects.ne_50m_admin_0_countries)
Insert cell
// Downloaded from Natural Earth (Ocean): https://www.naturalearthdata.com/downloads/50m-physical-vectors/
oceanTopo = FileAttachment("ne_50m_ocean.json").json()
Insert cell
oceanGeo = topojson.feature(oceanTopo, oceanTopo.objects.ne_50m_ocean)
Insert cell
Insert cell
phzs = d3.range(-60, 80, 10)
Insert cell
// via the U.S. Forest Service https://www.fs.usda.gov/rds/archive/catalog/RDS-2019-0001
// converted to NetCDF with GDAL
phz_ccsm45_2039 = FileAttachment("PHZ_ccsm45_2039.nc")
Insert cell
// via the U.S. Forest Service https://www.fs.usda.gov/rds/archive/catalog/RDS-2019-0001
// converted to NetCDF with GDAL
phz_ccsm45_2069 = FileAttachment("PHZ_ccsm45_2069.nc")
Insert cell
// via the U.S. Forest Service https://www.fs.usda.gov/rds/archive/catalog/RDS-2019-0001
// converted to NetCDF with GDAL
phz_ccsm45_2099 = FileAttachment("PHZ_ccsm45_2099.nc")
Insert cell
nc_ccsm45_2039 = phz_ccsm45_2039
.arrayBuffer()
.then((buffer) => new netcdf(buffer))
Insert cell
nc_ccsm45_2069 = phz_ccsm45_2069
.arrayBuffer()
.then((buffer) => new netcdf(buffer))
Insert cell
nc_ccsm45_2099 = phz_ccsm45_2099
.arrayBuffer()
.then((buffer) => new netcdf(buffer))
Insert cell
values_ccsm45_2039 = nc_ccsm45_2039.getDataVariable("Band1")
.map(d => d < -60 ? null : d)
Insert cell
values_ccsm45_2069 = nc_ccsm45_2069.getDataVariable("Band1")
.map(d => d < -60 ? null : d)
Insert cell
values_ccsm45_2099 = nc_ccsm45_2099.getDataVariable("Band1")
.map(d => d < -60 ? null : d)
Insert cell
lons = nc_ccsm45_2039.getDataVariable("lon")
Insert cell
lats = nc_ccsm45_2039.getDataVariable("lat")
Insert cell
// Combine values from the three time ranges
// The (lat, lon) pairs have the same indexes in each NetCDF
points = values_ccsm45_2039
.map((c_2039, i) => {
const lon = lons[i % lons.length];
const lat = lats[(i / lons.length) | 0];
const f_2039 = cToF(c_2039);

const c_2069 = values_ccsm45_2069[i];
const f_2069 = cToF(c_2069);

const c_2099 = values_ccsm45_2099[i];
const f_2099 = cToF(c_2099);
return {
lon,
lat,
phz_2039: fToPhz(f_2039),
phz_2069: fToPhz(f_2069),
phz_2099: fToPhz(f_2099)
}
})
Insert cell
// Sorted for dropdown menu
treesSorted = species.sort((a, b) => d3.ascending(a.name_common, b.name_common))
Insert cell
// via the Davey Tree Expert company, filtered to include only species in the continental U.S.
// As mentioned at the top of this notebook, I added a few species of my own
species = FileAttachment("species@1.json").json()
Insert cell
Insert cell
// Function to convert Fahrenheit to PHZ
function fToPhz(f) {
if (isNaN(f) || f === null || (f !== 0 && !f)) return undefined;
if (f < phzs[0]) return 0;

const l = phzs.length;
if (f >= phzs[l - 1]) return l - 1;
for (let i = 0; i < l; i++){
const v0 = phzs[i];
const v1 = phzs[i + 1];
// PHZ is [v0, v1)
if (f >= v0 && f < v1) {
return i + 1;
break;
}
}

return undefined;
}
Insert cell
// Function to convert Celsius to Fahrenheit
function cToF(c) {
return (isNaN(c) || c === null || (c !== 0 && !c)) ? undefined : c * 9 / 5 + 32;
}
Insert cell
// Function to convert a string to slug case
function toSlugCase(x) {
return x.toString().toLowerCase()
.replace(/\s+/g, "-") // Replace spaces with -
.replace(/[^\w\-]+/g, "") // Remove all non-word chars
.replace(/\-\-+/g, "-") // Replace multiple - with single -
.replace(/^-+/, "") // Trim - from start of text
.replace(/-+$/, ""); // Trim - from end of text
}
Insert cell
Insert cell
import { toc } from "@harrystevens/toc"
Insert cell
import { franklinLight } from "1dec0e3505bd3624"
Insert cell
netcdf = import("https://cdn.skypack.dev/netcdfjs@2.0.2?min").then((d) => d.NetCDFReader)
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