|
<!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 */ |
|
|
|
.protest { |
|
fill: rgba(252, 255, 163, 0.8); |
|
} |
|
|
|
.block { |
|
fill: rgba(249, 119, 47, 0.8); |
|
} |
|
|
|
.occupy { |
|
fill: rgba(233, 35, 26, 0.8); |
|
} |
|
|
|
.chased-away { |
|
fill: rgba(142, 105, 205, 0.8); /* Bruise color */ |
|
} |
|
|
|
/* Legend */ |
|
|
|
.legend rect { |
|
shape-rendering: crispEdges; |
|
} |
|
|
|
.legend .legendRow rect { |
|
stroke: #aaa; |
|
} |
|
|
|
/* 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"); |
|
|
|
// Add timeline legend. |
|
var legendData = [ |
|
{"id": "protest", "title": "масові протести"}, |
|
{"id": "block", "title": "заблоковані адмінбудівлі"}, |
|
{"id": "occupy", "title": "захоплені адмінбудівлі"}, |
|
{"id": "chased-away", "title": "силовий розгін"} |
|
]; |
|
var legend = { |
|
"width": 200, "height": 100, |
|
"padding": 20, "itemPadding": 10, |
|
"colorBlockSize": 15 |
|
}; |
|
var legendGroup = svg.append("g") |
|
.attr("class", "legend") |
|
.attr("transform", "translate(0, " + (height - legend.height - 2 * legend.padding) + ")"); |
|
var legendScale = d3.scale.ordinal() |
|
.domain(legendData.map(function(d) { return d.id; })) |
|
.rangeRoundBands([legend.padding, legend.height + legend.padding], 0.05); |
|
legendGroup.append("rect") |
|
.attr({"x": legend.padding, "y": legend.padding, |
|
"width": legend.width, "height": legend.height, |
|
"fill": "white", "stroke": "#aaa"}); |
|
var legendRows = legendGroup.selectAll(".legendRow") |
|
.data(legendData) |
|
.enter().append("g") |
|
.attr("class", "legendRow") |
|
.attr("transform", function(d) { |
|
return "translate(" + (legend.padding + legend.itemPadding) + ", " |
|
+ (legendScale(d.id) + 18) + ")" |
|
}); |
|
legendRows.append("rect") |
|
.attr("class", function(d) { return d.id; }) |
|
.attr({"x": 0, "y": -legend.colorBlockSize, |
|
"width": legend.colorBlockSize, "height": legend.colorBlockSize}); |
|
legendRows.append("text") |
|
.attr("x", legend.colorBlockSize + 5) |
|
.text(function(d) { return d.title; }); |
|
} |
|
|
|
/** |
|
* 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> |