|
<!DOCTYPE html> |
|
<head> |
|
|
|
<meta charset="utf-8"> |
|
<script src="https://unpkg.com/topogram"></script> |
|
<script src="https://unpkg.com/topojson"></script> |
|
<script src="https://unpkg.com/d3-composite-projections"></script> |
|
<script src="https://unpkg.com/d3-tip@0.9.1/dist/index.js"></script> |
|
<script src="https://d3js.org/d3.v4.js"></script> |
|
<style> |
|
|
|
body { |
|
margin:0; |
|
position:fixed; |
|
top:0; |
|
right:0; |
|
bottom:0; |
|
left:0; |
|
width:960; |
|
height:500;} |
|
.provinces { |
|
fill: #ccc; |
|
stroke: #fff; |
|
} |
|
div.tooltip { |
|
position: absolute; |
|
text-align: center; |
|
width: 70px; |
|
|
|
padding: 2px; |
|
font: 12px sans-serif; |
|
background: lightsteelblue; |
|
border: 0px; |
|
border-radius: 8px; |
|
pointer-events: none; |
|
} |
|
.d3-tip { |
|
line-height: 1; |
|
padding: 12px; |
|
background: rgba(0, 0, 0, 0.8); |
|
color: #fff; |
|
border-radius: 2px; |
|
pointer-events: none; |
|
} |
|
.d3-tip span { |
|
font-weight: bold; |
|
} |
|
|
|
/* 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); |
|
position: absolute; |
|
pointer-events: none; |
|
} |
|
|
|
/* Northward tooltips */ |
|
.d3-tip.n:after { |
|
content: "\25BC"; |
|
margin: -1px 0 0 0; |
|
top: 100%; |
|
left: 0; |
|
text-align: center; |
|
} |
|
|
|
/* Eastward tooltips */ |
|
.d3-tip.e:after { |
|
content: "\25C0"; |
|
margin: -4px 0 0 0; |
|
top: 50%; |
|
left: -8px; |
|
} |
|
|
|
/* Southward tooltips */ |
|
.d3-tip.s:after { |
|
content: "\25B2"; |
|
margin: 0 0 1px 0; |
|
top: -8px; |
|
left: 0; |
|
text-align: center; |
|
} |
|
|
|
/* Westward tooltips */ |
|
.d3-tip.w:after { |
|
content: "\25B6"; |
|
margin: -4px 0 0 -1px; |
|
top: 50%; |
|
left: 100%; |
|
} |
|
#selectors { |
|
position: absolute; |
|
top: 10px; |
|
left: 10px; |
|
} |
|
.selector { |
|
padding: 20px; |
|
margin: 10px; |
|
} |
|
#info { |
|
position: absolute; |
|
top: 70px; |
|
left: 20px; |
|
width: 200px; |
|
} |
|
|
|
#info span { |
|
font-weight: bold; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<div id=selectors> |
|
<select class=selector id=year-selector> |
|
</select> |
|
<select class=selector id=party-selector> |
|
</select> |
|
</div> |
|
<div id=info> |
|
|
|
</div> |
|
|
|
<script> |
|
// Feel free to change or delete any of the code you see in this editor! |
|
const width = 960, |
|
height = 500 |
|
const svg = d3.select("body").append("svg") |
|
.attr("width", width) |
|
.attr("height", height) |
|
|
|
const proj = d3.geoConicConformalSpain() |
|
|
|
const path = d3.geoPath().projection(proj) |
|
|
|
const party_colors = { |
|
'PSOE': '#b00003', |
|
'PP': '#064294', |
|
'CIUDADANOS': '#f53706', |
|
'UP/PODEMOS': '#6a195c', |
|
'VOX': '#4db729' |
|
} |
|
|
|
|
|
d3.queue() |
|
.defer(d3.json, "provinces.json") |
|
.defer(d3.json, "elecciones-3.json") |
|
.await(function(error, provinces, elecciones) { |
|
|
|
|
|
const data = elecciones['elecciones'] |
|
const norm_data = [] |
|
|
|
|
|
for (const [year, yearData] of Object.entries(data)) { |
|
for (const [party, provincesData] of Object.entries(yearData)) { |
|
for (const [province, provinceData] of Object.entries(provincesData)) { |
|
provinceData.s = Math.round(provinceData.s * Math.pow(10,2)) / Math.pow(10,2) |
|
norm_data.push({...provinceData, |
|
'year': year, |
|
'party': party, |
|
'province': province}) |
|
} |
|
} |
|
} |
|
|
|
const final_esc_year = {} |
|
d3.nest() |
|
.key(d => d.year) |
|
.key(d =>d.province) |
|
.rollup(d => d.map(c=>c.s).reduce((acc, current) => acc + current)) |
|
.entries(norm_data) |
|
.forEach(d => { |
|
const subObj = {} |
|
let s_sum = 0 |
|
d.values.forEach(c => { |
|
subObj[c.key] = c.value |
|
s_sum += c.value |
|
}) |
|
subObj['todos'] = s_sum |
|
final_esc_year[d.key] = subObj |
|
}) |
|
|
|
const nested_years_data = d3.nest().key(d => d.year).key(d => d.party).entries(norm_data) |
|
|
|
let sel_year = null, |
|
sel_party = null, |
|
sel_data = null |
|
|
|
const carto = topogram |
|
.cartogram() |
|
.projection(proj) |
|
.properties(function(d) { |
|
return d.properties |
|
}) |
|
|
|
var land = topojson.feature(provinces, provinces.objects.provinces) |
|
|
|
const tip = d3.tip() |
|
.attr('class', 'd3-tip') |
|
.html(function(d) { |
|
return `<span>${d.province} (${sel_party})</span><br> |
|
<span>escaños</span>: ${d['esc']}/${final_esc_year[sel_year][d.province]}<br> |
|
<span>coste medio</span>: ${d['votesc']} votos` |
|
}) |
|
|
|
svg.call(tip) |
|
svg.selectAll("path").data(land.features) |
|
.enter() |
|
.append("path") |
|
.attr("d", path) |
|
// .attr("transform", 'translate() scale(30)') |
|
.attr("fill", "black") |
|
.attr("stroke", "black") |
|
.style("stroke-opacity", 0.2) |
|
.on('mouseover', function(d){ |
|
// d3.select(this).style("opacity", 1.0) |
|
d3.select(this).style("stroke-opacity", 0.8) |
|
const cross = sel_data.filter(c => c.province == d.properties.name)[0] |
|
let tipData = {'province': d.properties.name} |
|
if(typeof cross !== 'undefined') { |
|
tipData = Object.assign(tipData, {'esc': cross.s, 'votesc': cross.c}) |
|
} else tipData = Object.assign(tipData, {'esc': 0, 'votesc': 0}) |
|
const direction = d3.mouse(this)[1] > height *0.25 ? "n" : "s" |
|
tip.direction(direction) |
|
tip.show(tipData, this) |
|
}) |
|
.on('mouseout', function(d){ |
|
d3.select(this).style("stroke-opacity", 0.2) |
|
tip.hide() |
|
}) |
|
|
|
svg.append("path") |
|
.attr("class","border") |
|
.style("fill","none") |
|
.style("stroke","#a0a0a0") |
|
.attr("d", proj.getCompositionBorders()) |
|
|
|
setTimeout(function() { |
|
d3.select('#party-selector') |
|
.on('change', partyChange) |
|
|
|
d3.select('#year-selector') |
|
.on('change', yearChange) |
|
|
|
const all_years = nested_years_data.map(d => d.key).reverse() |
|
|
|
d3.select('#year-selector') |
|
.selectAll('option') |
|
.data(all_years) |
|
.enter() |
|
.append('option') |
|
.text(d => d) |
|
|
|
document.getElementById("year-selector").options[0].selected = true |
|
yearChange() |
|
}, 1000) |
|
|
|
|
|
const opacityScale = d3.scaleLinear().domain([0,1]).range([0.1, 0.9]) |
|
|
|
function update() { |
|
sel_data = nested_years_data.filter(d => d.key == sel_year)[0].values.filter(d => d.key == sel_party)[0].values |
|
const sizeScale = d3.scaleLinear() |
|
.domain([d3.max(sel_data, d => d.c), 0]) |
|
.range([100, 1000]) |
|
|
|
carto.value(function(d) { |
|
const cross = sel_data.filter(c => c.province == d.properties.name)[0] |
|
|
|
if (typeof cross != 'undefined'){ |
|
return sizeScale(cross.c) |
|
} else { |
|
return 25 |
|
} |
|
}) |
|
const this_features = carto(provinces, provinces.objects.provinces.geometries).features; |
|
|
|
svg.selectAll("path") |
|
.data(this_features) |
|
.transition() |
|
.duration(750) |
|
.ease(d3.easeLinear) |
|
.attr('fill', d => { |
|
const cross = sel_data.filter(c => c.province == d.properties.name)[0] |
|
let percent = 0.05 |
|
if (typeof cross !== 'undefined') { |
|
percent = opacityScale(cross.s / final_esc_year[sel_year][d.properties.name]) |
|
} |
|
return hexToRGB(party_colors[sel_party], percent) |
|
}) |
|
.style("stroke-opacity", 0.2) |
|
.attr('d', carto.path) |
|
} |
|
|
|
function yearChange() { |
|
sel_year = d3.select('#year-selector').property('value') |
|
d3.select('#party-selector') |
|
.selectAll("*") |
|
.remove(); |
|
|
|
d3.select('#party-selector') |
|
.selectAll('option') |
|
.data(nested_years_data |
|
.filter(d => d.key == sel_year)[0].values.map(d => d.key)) |
|
.enter() |
|
.append('option').text(d => d); |
|
|
|
const partySelector = document.getElementById("party-selector") |
|
if (!sel_party) { |
|
partySelector.options[0].selected = true |
|
} else { |
|
let sel_idx = -1 |
|
for (var i = 0; i < partySelector.options.length; i++) { |
|
if (partySelector.options[i].label == sel_party) { |
|
sel_idx = i |
|
break |
|
} |
|
} |
|
if (sel_idx >= 0) |
|
partySelector.options[sel_idx].selected = true |
|
else |
|
partySelector.options[0].selected = true |
|
} |
|
partyChange() |
|
} |
|
|
|
function partyChange() { |
|
sel_party = d3.select('#party-selector').property('value') |
|
const partyPerf = nested_years_data |
|
.filter(d => d.key == sel_year)[0].values.filter(d=>d.key == sel_party)[0] |
|
let costAvg = partyPerf.values.map(d => d.c).reduce((acc, current) => { |
|
return acc + current |
|
}) / partyPerf.values.length |
|
costAvg = Math.round(costAvg * Math.pow(10,2)) / Math.pow(10,2) |
|
const totalEsc = partyPerf.values.map(d => d.s).reduce((acc, current) => { |
|
return acc + current |
|
}) |
|
|
|
const allEsc = final_esc_year[sel_year]['todos'] |
|
const percent = 100* Math.round(totalEsc/allEsc * Math.pow(10,2)) / Math.pow(10,2) |
|
|
|
d3.select('#info').selectAll("*").remove() |
|
d3.select('#info').html(`En <span>${sel_year}</span>, <span>${sel_party}</span> obtuvo <span>${totalEsc} escaños</span> de los <span>${allEsc} (${percent}%)</span> asignados a partidos nacionales, con un <span>coste medio</span> por escaño de <span>${costAvg} votos</span>.`) |
|
|
|
update() |
|
} |
|
}); |
|
|
|
function hexToRGB(hex, alpha) { |
|
var r = parseInt(hex.slice(1, 3), 16), |
|
g = parseInt(hex.slice(3, 5), 16), |
|
b = parseInt(hex.slice(5, 7), 16); |
|
|
|
if (alpha) { |
|
return "rgba(" + r + ", " + g + ", " + b + ", " + alpha + ")"; |
|
} else { |
|
return "rgb(" + r + ", " + g + ", " + b + ")"; |
|
} |
|
} |
|
|
|
</script> |
|
</body> |