This visualization is part of a series focused on Run 169. I'm trying to run a road race in each of Connecticut's 169 towns. Hover over a town to see its name, driving time from my town, and races in the next two weeks.
The choropleth map shows the 169 towns of Connecticut. For those towns I haven't yet run, the colors show if the town has a race within a week or within two weeks. The car icon can be dragged to only consider those towns within a certain driving time.
Note: driving times are approximate, assuming low traffic.
forked from jpasini's block: Resizable choropleth map
xxxxxxxxxx
<html>
<head>
<meta charset="utf-8">
<title>Map with filter for driving times</title>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://d3js.org/topojson.v2.min.js"></script>
<script src="d3-tip.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3-legend/2.24.0/d3-legend.min.js"></script>
<style>
@import url("https://fonts.googleapis.com/css?family=Roboto");
body {
font-family: Roboto, sans-serif;
font-size: 10pt;
}
/* Make the chart container fill the page using CSS. */
#chart {
position: fixed;
left: 0px;
right: 0px;
top: 0px;
bottom: 0px;
}
.car {
fill: #eaeaea; /*#fdbf6f;*/
stroke: #666;
stroke-width: 1px;
}
.active {
fill: #ff7f00;
stroke: #333;
}
.instructions {
fill: #666;
}
.townLabel {
fill: #333;
}
.carLabel {
fill: #333;
}
.carLine {
fill: #aaa;
}
.area {
stroke: #888; /* #fff;*/
stroke-width: 1px;
opacity: 0.9;
fill: #eaeaea;
transition: opacity 0.2s;
}
.area:hover {
fill: #ff7f00;
}
.reachable {
opacity: 1;
}
.unreachable {
opacity: 0.3;
}
.alreadyRun {
fill: #a6dba0;
}
.hasRaceSoon {
fill: #c2a5cf;
}
.hasRaceVerySoon {
fill: #7b3294;
}
.color-legend text {
font-size: 12pt;
color: #666;
}
.color-legend rect {
opacity: 0.9;
}
.d3-tip {
font-size: 12pt;
line-height: 1.2;
padding: 12px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
border-radius: 7px;
}
/* Creates a small triangle extender for the tooltip */
.d3-tip:after {
box-sizing: border-box;
display: inline;
font-size: 10px;
width: 100%;
line-height: 1;
color: rgba(0, 0, 0, 0.8);
content: "\25BC";
position: absolute;
text-align: center;
}
/* style northward tooltips differently */
.d3-tip.n:after {
margin: -1px 0 0 0;
top: 100%;
left: 0;
}
/* more tooltip formats */
.townname {
font-weight: bold;
}
.racedate {
color: skyblue;
font-weight: bold;
}
.racedistance {
color: orange;
font-weight: bold;
}
.racename {
color: #fff;
}
</style>
</head>
<body>
<div id="chart"></div>
<script>
const tip = d3.tip()
.attr("class", "d3-tip")
.offset([-10, 0])
.html(d => "<span class='townname'>" + d.properties.NAME10 + ":</span> <span>"
+ drivingTimesMap[d.properties.NAME10].timeString
+ " driving</span>"
+ "<span>"
+ (d.properties.NAME10 in racesSoonByTown ?
racesSoonByTown[d.properties.NAME10]
: "")
+ "</span>"
);
const chartDiv = document.getElementById("chart");
const svg = d3.select(chartDiv).append("svg");
const carSlider = {value: 40};
const colorScale = d3.scaleOrdinal()
.domain(["Race within 1 week", "Race within 2 weeks", "Town already run"])
.range(["#7b3294", "#c2a5cf", "#a6dba0"]);
// note: these colors must match the css above
// TODO: DRY principle: perhaps do colors programmatically
const legendColors = ["#7b3294", "#c2a5cf", "#a6dba0"];
const legendLabels = ["Race within 1 week", "Race within 2 weeks", "Town already run"];
function getMapScale(width, height) {
// known size of CT image for given scale
const baseScale = 12000;
const baseWidth = 453;
const baseHeight = 379;
const scale1 = baseScale*width/baseWidth;
const scale2 = baseScale*height/baseHeight;
return d3.min([scale1, scale2]);
}
function getSliderParameters(width, height) {
const scale = getMapScale(width, height);
return {
x: width/2 - scale/12000*50,
y: height/2 - scale/12000*160,
width: scale/12000*180,
scale: scale/120000
};
}
const drivingTimesMap = {};
build_driving_map = row => {
drivingTimesMap[row.Town] = {};
drivingTimesMap[row.Town].time = +row.DrivingTime;
const hours = Math.floor(+row.DrivingTime/60);
const mins = +row.DrivingTime - 60*hours;
if(hours > 0) {
drivingTimesMap[row.Town].timeString = hours + "h " + mins + " min";
} else {
drivingTimesMap[row.Town].timeString = mins + " min";
}
if(!(row.Town in raceHorizonByTown)) {
raceHorizonByTown[row.Town] = { 'daysToRace': 400, 'raceType': ""};
}
return row;
};
const racesRunMap = {};
build_races_run_map = row => {
racesRunMap[row.Town] = {};
racesRunMap[row.Town].distance = row.Distance;
return row;
};
const today = d3.timeDay(new Date());
const racesSoonByTown = {};
const raceHorizonByTown = {};
fmt = d3.format("02");
parseRaces = row => {
row.Month = +row.Month;
row.Day = +row.Day;
row.Weekday = +row.Weekday;
row.DateString = fmt(row.Month) + "/" + fmt(row.Day);
row.raceDay = d3.timeDay(new Date(2017, row.Month-1, row.Day));
const daysToRace = d3.timeDay.count(today, row.raceDay);
if(daysToRace >= 0 && daysToRace <= 14) {
const raceString = "<tr><td><span class='racedate'>" +
row["Date/Time"] +
"</span></td><td><span class='racedistance'>" +
row.Distance + "</span></td><td><span class='racename'>" +
row.Name + "</span></td></tr>";
if(row.Town in racesSoonByTown) {
racesSoonByTown[row.Town] += raceString;
} else {
racesSoonByTown[row.Town] = "<table>" + raceString;
}
const raceType = daysToRace <= 7 ? "hasRaceVerySoon" : "hasRaceSoon";
if(row.Town in raceHorizonByTown) {
if(daysToRace < raceHorizonByTown[row.Town].daysToRace) {
raceHorizonByTown[row.Town] = {
'daysToRace': daysToRace,
'raceType': raceType
};
}
} else {
raceHorizonByTown[row.Town] = {
'daysToRace': daysToRace,
'raceType': raceType
};
}
}
return row;
};
function completeTooltipTables() {
Object.keys(racesSoonByTown).forEach(
key => { racesSoonByTown[key] += "</table>"; }
);
}
function drivingTimeToString(drivingTimeMins) {
// convert driving time in minutes into a string
const hours = Math.floor(drivingTimeMins/60);
const mins = Math.round(drivingTimeMins - 60*hours);
const hoursString = (hours == 0) ? '' : hours + 'h ';
return hoursString + mins + " min";
}
svg.call(tip);
function dataLoaded(error, mapData, drivingTimes, racesRun, races) {
completeTooltipTables();
function redraw(){
// Extract the width and height that was computed by CSS.
const width = chartDiv.clientWidth;
const height = chartDiv.clientHeight;
const centerX = width/2;
const centerY = height/2;
// Use the extracted size to set the size of an SVG element.
svg
.attr("width", width)
.attr("height", height);
// Slider
const sliderParameters = getSliderParameters(width, height);
const sliderScale = d3.scaleLinear()
.domain([0, sliderParameters.width])
.range([0, 120])
.clamp(true);
carSlider.x = sliderScale.invert(carSlider.value);
let sliderG = svg.selectAll('.sliderGroup').data([sliderParameters]);
sliderG = sliderG
.enter().append('g')
.attr('class', 'sliderGroup')
.merge(sliderG)
.attr('transform', d => 'translate(' + d.x + ',' + d.y + ')');
const myTown = 'Avon';
const labelText = myTown;
let drivingTimeLabel = sliderG.selectAll('.townLabel').data([labelText]);
drivingTimeLabel = drivingTimeLabel
.enter().append('text')
.attr('class', 'townLabel')
.attr('text-anchor', 'end')
.text(d => d)
.merge(drivingTimeLabel)
.attr('x', -10*sliderParameters.scale)
.attr('y', 160*sliderParameters.scale)
.attr('font-size', (175*sliderParameters.scale) + 'px');
let carLine = sliderG.selectAll('.carLine').data([carSlider]);
carLine = carLine
.enter().append('rect')
.attr('class', 'carLine')
.merge(carLine)
.attr('x', 0)
.attr('y', 100*sliderParameters.scale)
.attr('width', d => d.x)
.attr('height', 10*sliderParameters.scale);
let carLabel = sliderG.selectAll('.carLabel').data([carSlider]);
carLabel = carLabel
.enter().append('text')
.attr('class', 'carLabel')
.attr('text-anchor', 'middle')
.merge(carLabel)
.text(d => drivingTimeToString(sliderScale(d.x)))
.attr('x', d => d.x + 300*sliderParameters.scale)
.attr('y', -50*sliderParameters.scale)
.attr('font-size', (120*sliderParameters.scale) + 'px');
const car = sliderG.selectAll('.car').data([carSlider]);
const pathString = "m 25,0 c -4.53004,0.0112 -12.12555,0.69055 -14.0625,6.05859 -5.07703,1.58895 -10.49326,2.14878 -10.14649,9.23437 l 6.23633,0.75782 c 0,0 0.45836,3.05148 3.51563,3.13672 3.05727,0.0852 4.03125,-2.89454 4.03125,-2.89454 l 28.49609,0.0684 c 0,0 1.50286,3.40622 5.20508,3.37696 3.70222,-0.0293 4.85742,-4.37696 4.85742,-4.37696 1.52171,0.005 3.11558,0.0922 4.37695,-0.20703 0.72421,-1.0742 0.63022,-2.1633 -0.33203,-2.23828 -0.0635,-0.005 0.70644,-2.07399 -0.16797,-3.46484 l -0.0859,-1.51563 c -0.85704,-0.4383 -1.83605,-0.7606 -2.92969,-0.74023 -1.55827,-2.22881 -10.56728,-1.44901 -16.36719,-1.96485 -1.45014,-0.83459 -2.9249,-2.47089 -4.51367,-4.27343 0,0 -2.90328,-0.91128 -4.92774,-0.89453 -0.50611,0.004 -1.67553,-0.0662 -3.18554,-0.0625 z m 1.83594,1.23437 c 1.42376,-0.0226 4.15534,0.26141 4.65625,0.51563 0.66787,0.33894 3.90428,3.44039 3.58398,3.87695 -0.3203,0.43656 -8.54696,0.58251 -9.01953,0.26758 -0.47258,-0.31493 -0.28696,-4.16971 -0.0762,-4.52344 0.0527,-0.0884 0.38088,-0.12919 0.85547,-0.13672 z m -3.3418,0.16016 c 0.19862,0.0111 0.33328,0.0434 0.38281,0.10156 0.39621,0.46517 0.29788,4.24032 -0.0234,4.38477 -0.26357,0.11849 -7.94003,0.75278 -8.31054,0.43945 -0.37051,-0.31334 0.16129,-2.35076 1.14648,-3.24024 0.86204,-0.77829 5.41436,-1.76307 6.80469,-1.68554 z"
const carScale = 10*sliderParameters.scale;
car
.enter().append('path')
.attr('class', 'car')
.attr('d', pathString)
.merge(car)
.attr('transform', d => 'translate(' + d.x + ') scale(' + carScale + ')')
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
// draw the color legend manually
let colorLegendG = svg.selectAll('.mapColorLegendG').data([sliderParameters]);
colorLegendG = colorLegendG
.enter().append('g')
.attr('class', 'mapColorLegendG')
.merge(colorLegendG)
.attr("transform", d => "translate(" + (50*d.scale) + "," + (d.y - 100*d.scale) + ")");
const colorLegend = colorLegendG.selectAll('rect').data(legendColors);
const legendLineHeight = 140*sliderParameters.scale;
colorLegend
.enter().append('rect')
.attr('x', 0)
.merge(colorLegend)
.attr('fill', d => d)
.attr('width', legendLineHeight*.9)
.attr('height', legendLineHeight*.9)
.attr('y', (d, i) => (i-0.3)*legendLineHeight);
const colorLegendText = colorLegendG.selectAll('text').data(legendLabels);
colorLegendText
.enter().append('text')
.attr('fill', d => d)
.attr('fill', '#666')
.attr('alignment-baseline', 'middle')
.html(d => d)
.merge(colorLegendText)
.attr('font-size', 0.75*legendLineHeight)
.attr('x', legendLineHeight)
.attr('y', (d, i) => (i + 0.2)*(legendLineHeight));
// add instructions and title
const instructions = svg.selectAll('.instructions').data([sliderParameters]);
instructions
.enter().append('text')
.attr('class', 'instructions')
.text('drag car to filter by driving time')
.merge(instructions)
.attr('x', d => d.x + 3*legendLineHeight)
.attr('y', d => d.y + 2.2*legendLineHeight)
.attr('font-size', d => 0.75*legendLineHeight);
function isReachable(town) {
return drivingTimesMap[town].time <= Math.round(carSlider.value);
}
// Start work on the choropleth map
// idea from https://www.youtube.com/watch?v=lJgEx_yb4u0&t=23s
const mapScale = getMapScale(width, height);
const CT_coords = [-72.7,41.6032];
const projection = d3.geoMercator()
.center(CT_coords)
.scale(mapScale)
.translate([centerX, centerY]);
const path = d3.geoPath().projection(projection);
let areas = svg.selectAll(".area")
.data(topojson.feature(mapData, mapData.objects.townct_37800_0000_2010_s100_census_1_shp_wgs84).features);
areas = areas
.enter()
.append("path")
.on("mouseover", tip.show)
.on("mouseout", tip.hide)
.merge(areas)
.attr('d', path)
.attr('class', d => {
reachableClass = isReachable(d.properties.NAME10) ?
' reachable' : ' unreachable';
return d.properties.NAME10 in racesRunMap ?
'area alreadyRun' + reachableClass :
'area ' + raceHorizonByTown[d.properties.NAME10].raceType + reachableClass;
});
function dragstarted(d) {
d3.select(this).raise().classed('active', true);
}
function dragged(d) {
d.x = d3.event.x < 0 ?
0 :
d3.event.x > sliderParameters.width ?
sliderParameters.width :
d3.event.x;
d.value = sliderScale(d.x);
// note trick: scale(1) before translate to ensure 1-to-1 ratio
// of pixels dragged and pixels translated
d3.select(this)
.attr('transform', 'scale(1) translate('+ d.x + ') scale(' + carScale + ')');
carLabel.merge(carLabel)
.attr('x', d.x + 300*sliderParameters.scale)
.text(d => drivingTimeToString(sliderScale(d.x)));
carLine.merge(carLine)
.attr('x', 0)
.attr('width', d => d.x);
areas.merge(areas)
.attr("class", d => {
reachableClass = isReachable(d.properties.NAME10) ?
' reachable' : ' unreachable';
return d.properties.NAME10 in racesRunMap ?
"area alreadyRun" + reachableClass :
"area " + raceHorizonByTown[d.properties.NAME10].raceType + reachableClass;
});
}
function dragended(d) {
d3.select(this).classed('active', false);
}
}
// Draw for the first time to initialize.
redraw();
// Redraw based on the new size whenever the browser window is resized.
window.addEventListener("resize", redraw);
}
d3.queue()
.defer(d3.json, "ct_towns_simplified.topojson")
.defer(d3.csv, "driving_times_from_avon.csv", build_driving_map)
.defer(d3.csv, "towns_run.csv", build_races_run_map)
.defer(d3.csv, "races2017.csv", parseRaces)
.await(dataLoaded);
</script>
</body>
</html>
https://d3js.org/d3.v4.min.js
https://d3js.org/topojson.v2.min.js
https://cdnjs.cloudflare.com/ajax/libs/d3-legend/2.24.0/d3-legend.min.js