Public
Edited
May 11
Insert cell
Insert cell
data = FileAttachment("Country_Indices__Quality_of_Life___Cost_of_Living.csv").csv({ typed: true })w2 2`2

Insert cell
world = d3.json("https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json")

Insert cell
topojson = await import("https://cdn.jsdelivr.net/npm/topojson-client@3/+esm")

Insert cell
viewof choropleth = {
const width = 960, height = 580;

const svg = d3.create("svg").attr("viewBox", [0, 0, width, height]);

const projection = d3.geoNaturalEarth1().scale(160).translate([width / 2, height / 2 + 20]);
const path = d3.geoPath().projection(projection);

const countries = topojson.feature(world, world.objects.countries).features;
const dataMap = new Map(data.map(d => [d.Country.toLowerCase(), d]));

const nameOverrides = {
"united states of america": "united states",
"russian federation": "russia",
"czechia": "czech republic",
"south korea": "korea, south",
"north macedonia": "macedonia",
"viet nam": "vietnam",
"taiwan": "taiwan",
"bosnia and herzegovina": "bosnia",
"slovakia": "slovak republic"
};

const colorScaleCost = d3.scaleThreshold()
.domain([20, 30, 40, 45, 50, 55, 60, 65, 80])
.range([
"#13ad09", "#18d80c", "#2bf41e", "#c3f95e", "#dff95e",
"#f9e25e", "#f9ba5e", "#f9995e", "#f97a5e", "#f95e5e"
]);

const colorScaleQuality = d3.scaleThreshold()
.domain([80, 100, 120, 130, 140, 150, 160, 170, 180])
.range([
"#f95e5e", "#f97a5e", "#f9995e", "#f9ba5e", "#f9e25e",
"#dff95e", "#c3f95e", "#2bf41e", "#18d80c", "#13ad09"
]);

const tooltip = d3.select("body").append("div")
.style("position", "absolute")
.style("background", "#fff")
.style("padding", "6px 12px")
.style("border", "1px solid #ccc")
.style("border-radius", "4px")
.style("font", "12px sans-serif")
.style("pointer-events", "none")
.style("opacity", 0);

const baseLayer = svg.append("g").attr("transform", "translate(0, 40)");
const topLayer = svg.append("g").attr("transform", "translate(0, 40)");

const clip = svg.append("clipPath")
.attr("id", "clip-slider")
.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", width / 2)
.attr("height", height);

topLayer.attr("clip-path", "url(#clip-slider)");

const legendWidth = 180;

const defs = svg.append("defs");

function createGradient(id, colors) {
const gradient = defs.append("linearGradient")
.attr("id", id)
.attr("x1", "0%")
.attr("x2", "100%")
.attr("y1", "0%")
.attr("y2", "0%");

const n = colors.length;
colors.forEach((color, i) => {
gradient.append("stop")
.attr("offset", `${(i / (n - 1)) * 100}%`)
.attr("stop-color", color);
});
}

createGradient("cost-gradient", colorScaleCost.range());
createGradient("quality-gradient", colorScaleQuality.range());

const costLegend = svg.append("g")
.attr("transform", `translate(30,${height - 50})`)
.style("pointer-events", "none");

const qualityLegend = svg.append("g")
.attr("transform", `translate(${width - 30 - legendWidth},${height - 50})`)
.style("pointer-events", "none");

let costMarker = costLegend.append("polygon").attr("fill", "#000").style("opacity", 0);
let qualityMarker = qualityLegend.append("polygon").attr("fill", "#000").style("opacity", 0);

function drawGradientLegend(group, title, gradientId) {
group.append("text")
.text(title)
.attr("y", -8)
.attr("font-size", 12)
.attr("font-weight", "bold");

group.append("rect")
.attr("width", legendWidth)
.attr("height", 10)
.attr("fill", `url(#${gradientId})`);

group.append("text").attr("x", 0).attr("y", 22).attr("font-size", 10).text("Low");
group.append("text").attr("x", legendWidth).attr("y", 22).attr("text-anchor", "end").attr("font-size", 10).text("High");
}

drawGradientLegend(costLegend, "Cost of Living Index", "cost-gradient");
drawGradientLegend(qualityLegend, "Quality of Life Index", "quality-gradient");

function drawMap(group, metric, scale, marker, min, max) {
const colors = scale.range();
group.selectAll("path")
.data(countries)
.join("path")
.attr("d", path)
.attr("fill", d => {
const rawName = d.properties.name.toLowerCase();
const key = nameOverrides[rawName] || rawName;
const entry = dataMap.get(key);
return entry ? scale(entry[metric]) : "#eee";
})
.attr("stroke", "#ccc")
.on("mousemove", (event, d) => {
const rawName = d.properties.name.toLowerCase();
const key = nameOverrides[rawName] || rawName;
const entry = dataMap.get(key);
if (!entry) return tooltip.style("opacity", 0);

const value = entry[metric];
const domain = scale.domain();
const idx = domain.findIndex(t => value < t);
const segment = idx === -1 ? domain.length : idx;
const markerX = (legendWidth / colors.length) * segment + 5;

marker.raise().attr("points", `${markerX - 4},-5 ${markerX + 4},-5 ${markerX},5`).style("opacity", 1);

tooltip.style("opacity", 1)
.html(`<b>${d.properties.name}</b><br>${metric}: ${value.toFixed(1)}`)
.style("left", event.pageX + 10 + "px")
.style("top", event.pageY - 28 + "px");
})
.on("mouseout", () => {
tooltip.style("opacity", 0);
marker.style("opacity", 0);
});
}

drawMap(baseLayer, "Quality of Life Index", colorScaleQuality, qualityMarker, 80, 180);
drawMap(topLayer, "Cost of Living Index", colorScaleCost, costMarker, 20, 80);

let xPos = width / 2;

const sliderArrow = svg.append("text")
.attr("text-anchor", "middle")
.attr("font-size", 16)
.attr("font-weight", "bold")
.attr("fill", "#555")
.text("⇔");

const slider = svg.append("line")
.attr("stroke", "#000")
.attr("stroke-width", 2)
.style("cursor", "ew-resize")
.call(d3.drag().on("drag", function (event) {
xPos = Math.max(0, Math.min(width, event.x));
svg.select("#clip-slider rect").attr("width", xPos);
slider.attr("x1", xPos).attr("x2", xPos);
sliderArrow.attr("x", xPos);
}));

slider.attr("x1", xPos).attr("x2", xPos).attr("y1", 40).attr("y2", height);
sliderArrow.attr("x", xPos).attr("y", height / 2);

svg.append("text")
.attr("x", width / 2)
.attr("y", 25)
.attr("text-anchor", "middle")
.attr("font-size", 22)
.attr("font-weight", "600")
.text("Comparing Cost of Living and Quality of Life Across Countries");

svg.append("text")
.attr("x", width - 10)
.attr("y", height - 5)
.attr("text-anchor", "end")
.attr("font-size", 11)
.attr("fill", "#666")
.text("Source: Numbeo.com, an online database of user-contributed data");

return svg.node();
}

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