You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

280 lines
7.8 KiB
JavaScript

/// <reference path="./d3.js" />
/// <reference path="./topojson.js" />
const ASPECT_RATIO = window.innerWidth / window.innerHeight;
const CANVAS_WIDTH = window.innerWidth ?? 1000;
// *0.9 for footer
const CANVAS_HEIGHT =
(ASPECT_RATIO > 1
? window.innerHeight * 0.9
: CANVAS_WIDTH * ASPECT_RATIO * 0.9) ?? 500;
const CHART_WIDTH = CANVAS_WIDTH * 0.8;
const CHART_HEIGHT = CANVAS_HEIGHT * 0.8;
const CHART_X_OFFSET = (CANVAS_WIDTH - CHART_WIDTH) / 2;
const CHART_Y_OFFSET = (CANVAS_HEIGHT - CHART_HEIGHT) / 2;
const STROKE_WIDTH = CHART_WIDTH / 1000;
const STROKE_COLOR = "black";
const BASE_SIZE = CHART_Y_OFFSET * 0.3;
const FONT_FAMILY = "quicksand, Sans-serif";
const PERCENT_HEXs = [
"#e5f5e0",
"#c7e9c0",
"#a1d99b",
"#74c476",
"#41ab5d",
"#238b45",
"#006d2c",
];
const fetchData = async () => {
return {
education: await (await fetch("./education.json")).json(),
map: await (await fetch("./map.json")).json(),
};
};
const featuresToPath = (features) => {
const paths = [];
let currentPath = [];
let currentObj = {};
const extractPolygons = (arr) => {
if (Array.isArray(arr[0][0])) {
arr.forEach((a) => {
extractPolygons(a);
});
} else {
const polygon = [];
arr.forEach((a) => {
polygon.push(`${a[0]},${a[1]}`);
});
currentPath.push("M" + polygon.join("L") + "Z");
}
};
features.forEach((feature) => {
currentObj = { ...feature };
delete currentObj.geometry;
extractPolygons(feature.geometry.coordinates);
paths.push({ ...currentObj, path: currentPath.join("") });
currentPath = [];
});
return paths;
};
const featuresToPolygon = (features) => {
const polygons = [];
let currentObj = {};
const extractPolygons = (arr) => {
if (Array.isArray(arr[0][0])) {
arr.forEach((a) => {
extractPolygons(a);
});
} else {
const polygon = [];
arr.forEach((a) => {
polygon.push(a[0], a[1]);
});
polygons.push({ ...currentObj, polygon: [...polygon] });
}
};
features.forEach((feature) => {
currentObj = { ...feature };
delete currentObj.geometry;
extractPolygons(feature.geometry.coordinates);
});
return polygons;
};
fetchData()
.then(({ education, map }) => {
const MAP_WIDTH = map.bbox[2];
const MAP_HEIGHT = map.bbox[3];
const scaleByWidth = CHART_HEIGHT / MAP_HEIGHT > CHART_WIDTH / MAP_WIDTH;
const scale = scaleByWidth
? CHART_WIDTH / MAP_WIDTH
: CHART_HEIGHT / MAP_HEIGHT;
const edObj = {};
education.forEach((county) => {
edObj[county.fips] = { ...county };
});
const states = topojson.feature(map, map.objects.states);
const statesBoundaries = featuresToPolygon(states.features);
const counties = topojson.feature(map, map.objects.counties);
const countiesBoundaries = featuresToPath(counties.features);
const MIN_PERCENT = 3;
const MAX_PERCENT = 66;
// 0.000000001 is to ensure that max percent do not yield percentBucketIndex as PERCENT_BUCKET_SIZE
const PERCENT_BUCKET_SIZE = Number(
((MAX_PERCENT - MIN_PERCENT + 0.000000001) / PERCENT_HEXs.length).toFixed(
10
)
);
const getFill = (percent) => {
const percentBucketIndex = Math.trunc(
(Math.min(Math.max(percent, MIN_PERCENT), MAX_PERCENT) - MIN_PERCENT) /
PERCENT_BUCKET_SIZE
);
return PERCENT_HEXs[percentBucketIndex];
};
const canvas = d3
.select("body")
.append("svg")
.attr("width", CANVAS_WIDTH)
.attr("height", CANVAS_HEIGHT);
canvas
.append("text")
.attr("id", "title")
.attr("x", "50%")
.attr("text-anchor", "middle")
.attr("y", BASE_SIZE * 1.7)
.attr("font-size", BASE_SIZE * 1.7)
.text("United States Educational Attainment");
canvas
.append("text")
.attr("id", "description")
.attr("x", "50%")
.attr("text-anchor", "middle")
.attr("y", BASE_SIZE * 3.33)
.attr("font-size", BASE_SIZE)
.text(
" Percentage of adults age 25 and older with a bachelor's degree or higher (2010-2014) "
);
const chart = canvas
.append("g")
.attr("id", "map")
.attr(
"transform",
`translate(${CHART_X_OFFSET * 1.8}, ${
CHART_Y_OFFSET * 1.5
}) scale(${scale})`
);
const mouseover = (e) => {
const obj = JSON.parse(JSON.stringify(e.target.dataset));
tooltip
.html(
`${edObj[obj.fips].area_name}, ${edObj[obj.fips].state}: ${
obj.education
}%`
)
.style("background-color", "#000000")
.style("opacity", 1)
.attr("data-education", obj.education);
};
const mousemove = (e) => {
tooltip.style("left", e.pageX + "px").style("top", e.pageY + "px");
};
const mouseleave = (d) => {
tooltip.style("opacity", 0);
};
chart
.selectAll(null)
.data(countiesBoundaries)
.enter()
.append("path")
.attr("class", "county")
.attr("d", (d) => d.path)
.attr("data-fips", (d) => d.id)
.attr("data-education", (d) => edObj[d.id].bachelorsOrHigher)
.attr("stroke", "none")
.attr("fill", (d) => getFill(edObj[d.id].bachelorsOrHigher))
.on("mouseover", mouseover)
.on("mousemove", mousemove)
.on("mouseleave", mouseleave);
chart
.selectAll(null)
.data(statesBoundaries)
.enter()
.append("polygon")
.attr("class", "state")
.attr("points", (d) => d.polygon)
.attr("stroke", "white")
.attr("fill", "none");
const legend = canvas
.append("g")
.attr("id", "legend")
.attr(
"transform",
`translate(${CHART_X_OFFSET + CHART_WIDTH * 0.55}, ${
CHART_Y_OFFSET * 1.5
})`
);
legend
.selectAll("rect")
.data(PERCENT_HEXs)
.enter()
.append("rect")
.attr("height", BASE_SIZE / 2)
.attr("width", BASE_SIZE * 2)
.attr("x", (d, i) => BASE_SIZE * 2 * i)
.attr("fill", (d) => d);
const legendScale = d3
.scaleLinear()
// percentage to proportions
.domain([MIN_PERCENT / 100, MAX_PERCENT / 100])
.range([0, PERCENT_HEXs.length * BASE_SIZE * 2]);
legend
.append("g")
.attr("id", "legend-scale-ticks")
.style("font", BASE_SIZE * 0.8 + "px " + FONT_FAMILY)
.call(
d3
.axisBottom(legendScale)
.tickValues(
d3.range(
// percentage to proportions
MIN_PERCENT / 100,
MAX_PERCENT / 100 + PERCENT_BUCKET_SIZE / 100,
PERCENT_BUCKET_SIZE / 100
)
)
.tickFormat(d3.format(".0%"))
)
.call((g) => g.select(".domain").remove())
.call((g) => g.selectAll("line").attr("y2", BASE_SIZE * 0.75))
.call((g) => g.selectAll("text").attr("y", BASE_SIZE));
canvas.selectAll("path").style("stroke-width", STROKE_WIDTH);
canvas.selectAll("line").style("stroke-width", STROKE_WIDTH);
let tooltip = d3
.select("body")
.append("div")
.attr("id", "tooltip")
.attr("class", "tooltip")
.attr("data-fips", "")
.attr("data-education", "")
.style("opacity", 0)
.style("position", "absolute")
.style("padding", `${CHART_WIDTH / 200}px ${CHART_WIDTH / 100}px`)
.style("margin", `0 ${CHART_WIDTH / 50}px`)
.style("border-radius", `${CHART_WIDTH / 100}px`)
.style("text-align", "center")
.style("font-size", BASE_SIZE + "px");
d3.select("body")
.append("footer")
.style("bottom", BASE_SIZE * 0.33 + "px")
.style("font-size", BASE_SIZE * 0.8 + "px")
.append("p")
.append("a")
.attr(
"href",
"https://radii.dev/freecodecamp-data-visualization/choropleth"
)
.text("</> Source Code & License");
})
.catch((e) => console.error("Error occurred!", e));