Public
Edited
May 13
1 fork
Insert cell
Insert cell
Insert cell
health_data = FileAttachment("health_workers_combined_2017_2024@2.csv").csv()
Insert cell
// convert type of year, latitude, longitude from strings to digits
cleaned_health_data = health_data.map(d => ({
...d,
year: +d.year,
latitude: +d.latitude,
longitude: +d.longitude
}))
Insert cell
health_data
SELECT * FROM health_data
Insert cell
worldGeo = FileAttachment("countries.json").json()
Insert cell
Insert cell
filteredCountries = ({
type: "FeatureCollection",
features: worldGeo.features.filter(d =>
["India", "Pakistan", "Afghanistan", "Iran"].includes(d.properties.admin)
)
})
Insert cell
filteredHealthData = health_data.filter(d => +d.year === selectedYear)
Insert cell
Insert cell
circle = d3.geoCircle().center([65, 25]).radius(22)()
Insert cell
viewof selectedYear = Inputs.range([2017, 2024], {step: 1, label: "Select Year"})
Insert cell

Plot.plot({
title: "Health Worker Incidents in South & Central Asia (2017–2025)",
projection: {type: "mercator",
domain: circle
},
width: 800,
height: 600,
margin: 40,
marks: [
Plot.geo(worldGeo, {
fill: "#f5f5f5",
stroke: "#999"
}),

Plot.dot(filteredHealthData, {
x: "longitude",
y: "latitude",
r: 2,
fill: "country",
opacity: 0.4
})
],
color: { legend: true }
})

Insert cell
filteredHealthData2 = health_data.filter(d => +d.year === selectedYear2)
Insert cell
viewof selectedYear2 = Inputs.range([2017, 2024], {step: 1, label: "Select Year"})
Insert cell

new_heatmap = Plot.plot({
title: "Health Worker Incidents in South & Central Asia (2017–2024)",
projection: {type: "mercator",
domain: circle
},
width: 800,
height: 600,
margin: 40,
marks: [
Plot.geo(worldGeo, {
fill: "#f5f5f5",
stroke: "#999"
}),

Plot.dot(filteredHealthData2, {
x: "longitude",
y: "latitude",
r: 2,
fill: "country",
opacity: 0.4
}),
Plot.density(filteredHealthData2, {
x: "longitude",
y: "latitude",
fill: "country",
thresholds: 10,
bandwidth: 10,
fillOpacity: 0.25,
mixBlendMode: "multiply"
})
],
color: {
legend: true}
})

Insert cell
Insert cell
timelineChart = Plot.plot({
title: "Health Worker Incidents Over Time by Country, 2017-2025",
marginLeft: 100,
marginRight: 40,
marginTop: 30,
marginBottom: 50,
inset: 10,
width: 800,
height: 500,
color: {legend: true},
style: {fontSize: 14},
y: {grid: true, nice: true, label: "Number of Incidents"},
x: {label: "Year"},

marks: [
// line per country showing total incidents per year
Plot.line(
health_data,
Plot.groupX(
{y: "count"},
{x: "year", stroke: "country", sort: "year"}
)
),

// dots for each country value
Plot.dot(
health_data,
Plot.groupX(
{y: "count"},
{x: "year", fill: "country", r: 5, sort: "year"}
)
),

Plot.ruleY([0])
]
})

Insert cell
Insert cell
Insert cell
countries = ["India", "Pakistan", "Afghanistan", "Iran"];
Insert cell
colorScale = d3.scaleOrdinal()
.domain(countries)
.range(["#ffd92f", "#66c2a5", "#8da0cb", "#fc8d62"]);
Insert cell
viewof selectedYear3 = Inputs.range([2017, 2024], {step: 1, label: "Select Year"})
Insert cell
heatmap_d3 = {
const width = 800, height = 600;
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height);

// create the base map projection and center it over south and central Asia
const projection = d3.geoMercator()
.center([75, 30])
.scale(700)
.translate([width / 2, height / 2]);
const path = d3.geoPath(projection);

// draw the base map
svg.append("g")
.selectAll("path")
.data(worldGeo.features)
.join("path")
.attr("fill", "#f5f5f5")
.attr("stroke", "#999")
.attr("d", path);

// layer for density contour
const densityGroup = svg.append("g");
// layer for individual points
const dotGroup = svg.append("g");

// create a function to update the map
function updateMap(year) {
// filter data for the selected year by the slider
const data = cleaned_health_data.filter(d => d.year === year);

// create density contours per country
const byCountry = d3.group(data, d => d.country);
const contours = [];
// From ChatGPT: group data by country
for (const [country, points] of byCountry) {
const cds = d3.contourDensity()
.x(d => projection([d.longitude, d.latitude])[0])
.y(d => projection([d.longitude, d.latitude])[1])
.size([width, height])
.bandwidth(10)
.thresholds(10)
(points);
cds.forEach(c => c.country = country);
contours.push(...cds);
}

// remove previously drawn contours
densityGroup.selectAll("path").remove();
densityGroup.selectAll("path")
.data(contours)
// update contours for the current year
.join("path")
.attr("d", d3.geoPath())
.attr("fill", d => colorScale(d.country))
.attr("fill-opacity", 0.25)
.style("mix-blend-mode", "multiply")
.attr("stroke", "none");

// add dots on top
dotGroup.selectAll("circle").remove();
dotGroup.selectAll("circle")
.data(data)
.join("circle")
.attr("cx", d => projection([d.longitude, d.latitude])[0])
.attr("cy", d => projection([d.longitude, d.latitude])[1])
.attr("r", 2)
.attr("fill", d => colorScale(d.country))
.attr("opacity", 0.4);
}

// slider interaction
const slider = viewof selectedYear3;
slider.addEventListener("input", () => {
updateMap(+slider.value);
});

// add legends
const legend = svg.append("g")
.attr("transform", `translate(${width - 140}, 40)`);
countries.forEach((c, i) => {
const g = legend.append("g")
.attr("transform", `translate(0, ${i * 20})`);
g.append("rect")
.attr("width", 12)
.attr("height", 12)
.attr("fill", colorScale(c));
g.append("text")
.attr("x", 16)
.attr("y", 6)
.attr("dy", "0.35em")
.text(c);
});

// auto animate on load
// (async () => {
// for (let y = 2017; y <= 2024; y++) {
// slider.value = y;
// updateMap(y);
// await new Promise(r => setTimeout(r, 800));
// }
// })();

svg.append("text")
.attr("x", width / 2)
.attr("y", 24)
.attr("text-anchor", "middle")
.style("font", "bold 18px sans-serif")
.text("Health Worker Incidents in Afghanistan, India, Iran, and Pakistan (2017–2024)");

// initial draw
slider.value = 2017;
updateMap(2017);

return svg.node();
}

Insert cell
Insert cell
lineChart = {
const margin = {top: 40, right: 150, bottom: 50, left: 60};
const width = 800 - margin.left - margin.right;
const height = 500 - margin.top - margin.bottom;

// group data by country and year
const counts = d3.rollup(
cleaned_health_data,
v => v.length,
d => d.country,
d => d.year
);
// convert each grouped data to an array, e.g., { country: "India", year: 2018, count: 253 }
const flat = Array.from(counts, ([country, yearMap]) =>
Array.from(yearMap, ([year, count]) => ({country, year: +year, count}))
).flat();

// determine the overall year range
const [minYear, maxYear] = d3.extent(flat, d => d.year);
const lineDuration = 2000;

// From ChatGPT: create an array that contains each country's name and corresponding sorted data
const dataByCountry = countries.map(country => {
const values = flat
.filter(d => d.country === country)
.sort((a, b) => d3.ascending(a.year, b.year));
return [country, values];
});

// define scales that map years to horizontal position
const x = d3.scaleLinear()
.domain(d3.extent(flat, d => d.year))
.range([0, width]);

// define scales that map incident count to vertical position
const y = d3.scaleLinear()
.domain([0, d3.max(flat, d => d.count)]).nice()
.range([height, 0]);

// create lines that connect data points
const line = d3.line()
.x(d => x(d.year))
.y(d => y(d.count));

// create svg
const svg = d3.create("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom);

svg.append("text")
.attr("x", (width + margin.left + margin.right) / 2)
.attr("y", margin.top / 2)
.attr("text-anchor", "middle")
.style("font", "bold 16px sans-serif")
.text("Health Worker Incidents Over Time by Country (2017–2024)");

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

// axes
g.append("g")
.attr("class", "grid")
.call(d3.axisLeft(y).tickSize(-width).tickFormat(""))
.selectAll("line")
.attr("stroke", "#ccc")
.attr("stroke-opacity", 0.7)
.select(".domain").remove();
g.append("g")
.call(d3.axisLeft(y));
g.append("g")
.attr("transform", `translate(0,${height})`)
.call(d3.axisBottom(x).tickFormat(d3.format("d")));

// From ChatGPT: draw and animate each country’s line
const countryG = g.selectAll(".country")
.data(dataByCountry)
.join("g")
.attr("class", "country");

countryG.append("path")
.attr("d", ([, values]) => line(values))
.attr("fill", "none")
.attr("stroke", ([country]) => colorScale(country))
.attr("stroke-width", 2)
.attr("stroke-dasharray", function() {
const L = this.getTotalLength();
return `${L} ${L}`;
})
.attr("stroke-dashoffset", function() {
return this.getTotalLength();
})
.transition()
.delay(0)
.duration(2000)
.ease(d3.easeLinear)
.attr("stroke-dashoffset", 0);
g.append("text")
.attr("x", width / 2)
.attr("y", height + margin.bottom - 10)
.attr("text-anchor", "middle")
.style("font", "12px sans-serif")
.text("Year");
g.append("text")
.attr("transform", "rotate(-90)")
.attr("x", -height / 2)
.attr("y", -margin.left + 15)
.attr("text-anchor", "middle")
.style("font", "12px sans-serif")
.text("Number of Incidents");


// From ChatGPT: circles fade in after each line draws
countryG.selectAll("circle")
.data(([, values]) => values)
.join("circle")
.attr("cx", d => x(d.year))
.attr("cy", d => y(d.count))
.attr("r", 0)
.attr("fill", d => colorScale(d.country))
.transition()
.delay(d => {
const t = (d.year - minYear) / (maxYear - minYear);
return t * lineDuration;
})
.duration(200)
.attr("r", 4)
.attr("opacity", 0.8);

// legend using the same global variables
const legend = svg.append("g")
.attr("transform", `translate(${width + margin.left + 20},${margin.top})`);

countries.forEach((c, i) => {
const row = legend.append("g")
.attr("transform", `translate(0, ${i * 20})`);
row.append("rect")
.attr("width", 12)
.attr("height", 12)
.attr("fill", colorScale(c));
row.append("text")
.attr("x", 16)
.attr("y", 6)
.attr("dy", "0.35em")
.text(c);
});

return svg.node();
}

Insert cell
Insert cell
filteredData = health_data.filter(d =>
["Demonstrations", "Political violence", "Strategic developments"]
.includes(d.disorder_type)
);
Insert cell
Insert cell
raw_bars = Plot.plot({
title: "Total Incident Types by Country (2017–2024)",
color: {legend: true,
scheme: "Accent",
swatchSize: 25
},
x: {tickRotate: 35, padding: 0.3, label: null},
y: {label: "Count of Disorder Events", nice: true, labelAnchor: "center"},
marginLeft: 80,
marginTop: 20,
marginBottom: 140,
marginRight: 80,
fx: { label: null },
height: 600,
width: 1000,
grid: true,
style: {
fontSize: "14px"
},
marks: [
Plot.barY(
filteredData,
Plot.groupX(
{y: "count"},
{x: "disorder_type", fx: "country", fill: "disorder_type"}
)
),

Plot.text(
filteredData,
Plot.groupX(
{y: "count", text: "count"},
{
x: "disorder_type",
fx: "country",
dy: -10,
fill: "black",
textAnchor: "middle"
}
)
),

Plot.frame()
]
});

Insert cell
Insert cell
population = [{
"India": 1463865525,
"Pakistan": 255219554,
"Afghanistan": 43844111,
"Iran": 92417681
}]
Insert cell
normalized_incidents = {
const allowedTypes = ["Demonstrations", "Political violence", "Strategic developments"];

// step 1: count (country, disorder_type)
const counts = d3.rollup(
health_data.filter(d => allowedTypes.includes(d.disorder_type)),
v => v.length,
d => d.country,
d => d.disorder_type
);

// step 2: flatten and normalize per 100k
return Array.from(counts, ([country, typeMap]) =>
Array.from(typeMap, ([disorder_type, count]) => ({
country,
disorder_type,
per100k: (count / population[0][country]) * 100000
}))
).flat();
}
Insert cell
Plot.plot({
title: "Normalized Total Incident Types by Country (2017–2024)",
// subtitle: "Incidents per 100k Residents, Adjusted by Each Country's 2024 Population",
color: {legend: true,
scheme: "Accent",
swatchSize: 25
},
x: { tickRotate: 35, padding: 0.3, label: null},
y: { label: "Per 100k Residents", nice: true, labelAnchor: "center" },
marginLeft: 80,
marginTop: 20,
marginBottom: 140,
marginRight: 80,
fx: { label: null },
width: 1000,
height: 600,
grid: true,
style: {fontSize: "14px"},
marks: [
Plot.barY(
normalized_incidents,
{ x: "disorder_type", y: "per100k", fx: "country", fill: "disorder_type" }
),
Plot.text(
normalized_incidents,
{
x: "disorder_type", y: "per100k", fx: "country",
text: d => d.per100k.toFixed(3),
dy: -6,
fill: "black",
textAnchor: "middle"
}
),
Plot.frame()
]
});

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