Skip to content

Instantly share code, notes, and snippets.

@vsapsai
Last active August 29, 2015 13:57
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save vsapsai/9730443 to your computer and use it in GitHub Desktop.
Save vsapsai/9730443 to your computer and use it in GitHub Desktop.
Map with timeline.

Demonstrate how you can display on a map data that changes during some period of time.

Please, note that this example is not a reliable source of information about 2014 Ukrainian Regional State Administration occupations. I've tried to be correct, but I might have done a few mistakes. See data_explanation.md for more details.

У якості основного джерела інформації для даного прикладу я взяв Протести в областях України у січні 2014. Також я використовував і інші джерела. При підготовці даної візуалізації, скоріше всього, я припустився ряду помилок. Але моїм головним завданням є демонстрація можливостей D3, а не висвітлення протестів. При підготовці даних я зробив наступні припущення та спрощення.

  • Неточні дані в перший день. Станом на 22 січня мітинги проводили не тільки в Києві.

  • Ототожнення Києву і Києвської області. Хоч і була захоплена Київська міська держадміністрація, а не Київська обласна держадміністрація, на карті позначена вся область.

  • Відсутність чіткої межі між різними результатами. Інколи важко розрізнити чи ОДА заблокована чи це лише мітинг, штурм ОДА лише планувався чи штурм був відбитий міліцією. Так що моя інтерпретація подій може бути неправильною.

  • Не завжди взяття штурмом адмінбудівлі означає захоплення саме ОДА. В деяких областях захоплювали обласні ради, причому в деяких областях облдержадміністрація та облрада знаходяться в різних частинах однієї і тієї ж будівлі.

[
{"date": "2014-01-22",
"events": {"kiev": "occupy"}},
{"date": "2014-01-23",
"events": {"lviv": "occupy",
"if": "block",
"km": "block",
"zt": "protest",
"te": "occupy",
"rv": "occupy",
"ck": "chased-away",
"pl": "protest"}},
{"date": "2014-01-24",
"events": {"cv": "occupy",
"if": "occupy",
"km": "occupy",
"zt": "block",
"volyn": "block",
"uz": "protest",
"pl": "block",
"kr": "protest",
"sm": "protest",
"cn": "protest"}},
{"date": "2014-01-25",
"events": {"uz": "block",
"pl": "occupy",
"vn": "occupy",
"dp": "protest",
"mk": "protest",
"cn": "occupy",
"kh": "protest",
"ks": "protest"}},
{"date": "2014-01-26",
"events": {"dp": "chased-away",
"zp": "chased-away",
"od": "protest",
"sm": "occupy"}},
{"date": "2014-01-27",
"events": {"sm": "chased-away"}}
]
<!DOCTYPE html>
<meta charset="utf-8">
<style>
svg {
background-color: rgb(191, 216, 242);
}
.country {
fill: #eee;
}
.country.UKR {
fill: #fff;
}
.country-boundary {
fill: none;
stroke: #aaa;
}
.regions {
fill: none;
}
.region-boundary {
fill: none;
stroke: #777;
stroke-dasharray: 2,2;
stroke-linejoin: round;
}
.region-label {
fill: #555;
fill-opacity: 1.;
font-size: 15px;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-weight: 300;
text-anchor: middle;
}
.coastline, .river, .lake {
stroke: rgb(83, 168, 231);
}
.coastline, .river {
fill: none;
}
.lake {
fill: rgb(191, 216, 242);
}
/* Timeline styling */
.regions .protest {
fill: rgba(252, 255, 163, 0.8);
}
.regions .block {
fill: rgba(249, 119, 47, 0.8);
}
.regions .occupy {
fill: rgba(233, 35, 26, 0.8);
}
.regions .chased-away {
fill: rgba(142, 105, 205, 0.8); /* Bruise color */
}
/* Slider */
.slider #slider-value {
text-align: center;
}
.slider input[type="range"] {
width: 100%;
}
.slider .min-label {
float: left;
}
.slider .max-label {
float: right;
}
</style>
<body>
<script src="http://d3js.org/d3.v3.min.js"></script>
<script src="http://d3js.org/topojson.v1.min.js"></script>
<script src="http://d3js.org/queue.v1.min.js"></script>
<script>
var width = 900,
height = 600;
var geometryCenter = {"latitude": 48.360833, "longitude": 31.1809725};
var container = d3.select("body").append("center");
var svg = container.append("svg")
.attr("width", width)
.attr("height", height);
queue()
.defer(d3.json, "/vsapsai/raw/8888013/ukraine.json")
.defer(d3.json, "eventsData.json")
.await(ready);
// Creates the visualization after the data is loaded.
function ready(error, ukraineData, timelineEventsData) {
var projection = d3.geo.conicEqualArea()
.center([0, geometryCenter.latitude])
.rotate([-geometryCenter.longitude, 0])
.parallels([46, 52]) // vsapsai: selected these parallels myself, most likely they are wrong.
.scale(4000)
.translate([width / 2, height / 2]);
var path = d3.geo.path()
.projection(projection);
var countries = topojson.feature(ukraineData, ukraineData.objects.countries);
svg.selectAll(".country")
.data(countries.features)
.enter().append("path")
.attr("class", function(d) { return "country " + d.id; })
.attr("d", path);
svg.append("path")
.datum(topojson.mesh(ukraineData, ukraineData.objects.countries, function(a, b) { return a !== b; }))
.attr("class", "country-boundary")
.attr("d", path);
svg.append("path")
.datum(topojson.mesh(ukraineData, ukraineData.objects.countries, function(a, b) { return a === b; }))
.attr("class", "coastline")
.attr("d", path);
var waterGroup = svg.append("g")
.attr("id", "water-resources");
var rivers = topojson.feature(ukraineData, ukraineData.objects.rivers);
waterGroup.selectAll(".river")
.data(rivers.features)
.enter().append("path")
.attr("class", "river")
.attr("name", function(d) { return d.properties.name; })
.attr("d", path);
// Add lakes after rivers so that river lines connect reservoirs, not cross them.
var lakes = topojson.feature(ukraineData, ukraineData.objects.lakes);
waterGroup.selectAll(".lake")
.data(lakes.features)
.enter().append("path")
.attr("class", "lake") // Note: not necessary a lake, it can be a reservoir.
.attr("name", function(d) { return d.properties.name; })
.attr("d", path);
var regions = topojson.feature(ukraineData, ukraineData.objects.regions);
// -- areas
var regionElements = svg.append("g")
.attr("class", "regions").selectAll("path")
.data(regions.features)
.enter().append("path")
.attr("id", function(d) { return d.id; })
.attr("d", path);
// -- boundaries
svg.append("path")
.datum(topojson.mesh(ukraineData, ukraineData.objects.regions, function(a, b) { return a !== b; }))
.classed("region-boundary", true)
.attr("d", path);
// -- labels
svg.selectAll(".region-label")
.data(regions.features)
.enter().append("text")
.attr("transform", function(d) { return "translate(" + projection(d.properties.label_point) + ")"; })
.classed("region-label", true)
.selectAll("tspan")
.data(function(d) { return d.properties.localized_name.ua.split(" "); })
.enter().append("tspan")
.attr("x", "0")
.attr("dy", function(d, i) { return i > 0 ? "1.1em" : "0"; })
.text(function(d) { return d + " "; });
// Add timeline.
var timelineStateData = stateDataFromEvents(timelineEventsData);
var possibleDates = timelineEventsData.map(function(d) { return d.date; });
updateElementsState(regionElements, timelineStateData[possibleDates[0]]);
createSlider(container, possibleDates, dateLabel, function(date) {
updateElementsState(regionElements, timelineStateData[date]);
})
.style("width", width / 2 + "px");
}
/**
* Updates each element so it has class the same as its state.
*/
function updateElementsState(elements, stateDictionary) {
elements.attr("class", function(d) {
var elementState = stateDictionary[d.id];
return (elementState !== undefined) ? elementState : "";
});
}
/**
* Creates DOM elements for a slider.
*
* @param parent D3 selection to which to append slider.
* @param values Array of possible values.
* @param valueLabelCallback Function which converts value to a human-readable
* form. Callback's signature is
* String function(ValueType). This callback is used
* to display min, max and current values.
* @param updateCallback Function which is called every time the user
* changes slider's value. Signature is
* void function(ValueType).
*/
function createSlider(parent, values, valueLabelCallback, updateCallback) {
if (values.length < 2) {
throw new Error("Not enough values. Expect at least 2 values");
}
if (valueLabelCallback === undefined) {
valueLabelCallback = function(value) { return value; };
}
var defaultIndex = 0;
var container = parent.append("div")
.attr("class", "slider");
var currentValueLabel = container.append("div")
.attr("id", "slider-value")
.text(valueLabelCallback(values[defaultIndex]));
var slider = container.append("input")
.attr({"type": "range",
"min": "0",
"max": values.length - 1,
"value": "0"})
.on("input", function() {
var newValue = values[this.value];
currentValueLabel.text(valueLabelCallback(newValue));
if (updateCallback) {
updateCallback(newValue);
}
});
container.append("br");
container.append("span")
.attr("class", "min-label")
.text(valueLabelCallback(values[0]));
container.append("span")
.attr("class", "max-label")
.text(valueLabelCallback(values[values.length - 1]));
return container;
}
/**
* Converts eventsData to stateData.
*
* For the purposes of this example events are sticky. It means if some event
* happened in a region, this region will keep this event state until different
* event happens in this region.
*
* For the visualization we need all intermediate states, but specifying all
* these states manually is tedious. That's why we specify events only.
*/
function stateDataFromEvents(eventsData) {
var result = {};
var previousDayDate = null;
var previousDayState = {};
eventsData.forEach(function(dayData) {
if ((previousDayState !== null) && (dayData.date >= previousDayState)) {
throw new Error("Dates in events data should be in ascending order");
}
var dayEvents = dayData.events;
var dayState = cloneDictionary(previousDayState);
Object.keys(dayEvents).forEach(function(regionCode) {
dayState[regionCode] = dayEvents[regionCode];
});
result[dayData.date] = dayState;
previousDayState = dayState;
});
return result;
}
function cloneDictionary(dictionary) {
var result = {};
Object.keys(dictionary).forEach(function(key) {
result[key] = dictionary[key];
});
return result;
}
/**
* Converts ISO date string into user-friendly date string.
*
* For example, converts "2014-01-24" to "24 січня".
*/
function dateLabel(isoDateString) {
var dateComponents = isoDateString.split("-");
// Wrap parseInt by anonymous function to avoid passing unnecessary arguments to parseInt.
dateComponents = dateComponents.map(function(d) { return parseInt(d); });
var year = dateComponents[0], month = dateComponents[1], day = dateComponents[2];
if ((year !== 2014) || (month !== 1)) {
throw new Error("Invalid date. Only January, 2014 is supported");
}
return day + " січня";
}
d3.select(self.frameElement)
.style("width", width + "px")
.style("height", "700px");
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment