280 lines
7.8 KiB
JavaScript
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));
|