scaleSequential = (await import("https://cdn.jsdelivr.net/npm/d3@7/+esm")).scaleSequential
// a format for dates
formatDate = d3.utcFormat("%b %d, %Y") // e.g., "Apr 18, 2025"
// counties
// FSA county geometry from the fsa-counties-dd22 archive, keyed on the 5-character
// FSA county code.
//
// dd22 rather than dd17: it resolves at least as many FSA codes as dd17 in every
// program year, and alone defines 16079 (Shoshone, ID). Five records of 51,348
// match dd17's boundaries instead — 31045 and 31165 in 2008, 51087 in 2010.
us = await d3.json("https://data.sustainable-fsa.com/fsa-counties-dd22/fsa-counties-dd22.topojson");
counties = topojson.feature(us, us.objects.counties)
// states, but just the boundaries between them
states = topojson.mesh(us, us.objects.states, (a, b) => a !== b)
// normal grazing period data, simplified
data = FileAttachment("assets/fsa-lfp-eligibility-simple.csv").csv({typed: true})
// Load color palettes
color =
new Map([
["1 Month", "#FFFF54"],
["2 Month", "#F3AE3D"],
["3 Month", "#6D4E16"],
["4 Month", "#EA3323"],
["5 Month", "#7316A2"],
["Eligible", "#EA3323"]
])
variable = "payment_type"filtered = data.filter(d => d.year === year && d.type === type && d.disaster === disaster)
// Create a lookup table keyed by `id`
valueById = Object.fromEntries(filtered.map(d => [String(d.id).padStart(5, "0"), d[variable]]))
// Merge values into counties.features
countiesFiltered = {
// Build lookup from filtered CSV: id → full row
const rowById = Object.fromEntries(
filtered.map(d => [String(d.id).padStart(5, "0"), d])
);
// Rebuild counties with joined properties
return {
type: "FeatureCollection",
features: counties.features.map(f => {
const id = String(f.id);
const row = rowById[id];
return {
...f,
value: row ? row[variable] : null,
properties: {
id: f.id,
...f.properties,
...(row ?? {}),
value: row ? row[variable] : null // explicitly add value for coloring
}
};
})
};
}// Size the map to its card. A ResizeObserver notifies as soon as the card
// receives its real dimensions, so the FIRST render is correctly sized —
// the stdlib `width` is only re-evaluated on window resize, which left the
// initial paint at the wrong scale until the viewport changed.
mapSize = Generators.observe((notify) => {
const body = document.querySelector(".card .card-body");
const ro = new ResizeObserver((entries) => {
const r = entries[0].contentRect;
if (r.width > 0 && r.height > 0) notify({width: r.width, height: r.height});
});
ro.observe(body);
return () => ro.disconnect();
})viewof disaster = Inputs.radio(
["Drought", "Fire"],
{
label: "Disaster",
value: "Drought"
}
)
viewof year = Inputs.range(
d3.extent(data, d => d.year),
{
step: 1,
label: "Year",
value: d3.max(data, d => d.year)
}
)
viewof type = {
const options = disaster === "Fire"
? ["Rangeland"]
: [...new Set(data.map(d => d.type))].sort();
return Inputs.select(options, { label: "Type", value: "Native Pasture" });
}Plot.plot({
width: mapSize.width,
height: Math.max(300, mapSize.height - 70), // leave room for the legend
projection: {
type: "albers",
domain: counties
},
grid: true,
color: (disaster === "Drought" ?
{
domain: ["1 Month", "2 Month", "3 Month", "4 Month", "5 Month"],
range: ["#FFFF54", "#F3AE3D", "#6D4E16", "#EA3323", "#7316A2"],
unknown: "#ccc",
legend: true
} :
{
domain: ["Eligible"],
range: ["#EA3323"],
unknown: "#ccc",
legend: true
}),
marks: [
Plot.geo(countiesFiltered, {
fill: "value"
}),
Plot.dot(
countiesFiltered.features.map(f => {
const [x, y] = d3.geoCentroid(f);
return {
...f.properties,
x,
y
};
}),
{
x: "x",
y: "y",
r: 3,
fill: "transparent",
stroke: "none",
tip: true,
title: d => `${d.county}, ${d.state}
FSA county code: ${String(d.id).padStart(5, "0")}
Program Year: ${year}
Crop Type: ${type}
Disaster: ${d.disaster ? d.disaster : "None"}
Drought Qualifier: ${d.qualifier ? d.qualifier : "None"}
Start Date: ${d.disaster_start_date ? formatDate(d.disaster_start_date) : "None"}
`
}
),
Plot.geo(states, {stroke: "white"}),
]
})