Skip to content
Snippets Groups Projects
Commit c241399e authored by viclope's avatar viclope
Browse files

Upload New File

parent 8328422c
No related branches found
No related tags found
No related merge requests found
// Constantes de la visualización
// svgHeight: altura total del svg
const colors = { // mapeo de colores asociados a cada país
China: '#EE2E2E',
'United States': '#9B54C3',
India: '#FF9933',
Russia: '#3574EE',
Japan: '#FFDD00',
Germany: '#000',
Iran: '#C38865',
'Saudi Arabia': '#006C35'
},
svgWidth = 1000, // anchura total del svg
svgHeight = 620, // altura total del svg
hPadding = 40, // espaciado interior lateral del svg con la visualización
vPadding = 30, // espaciado interior vertical del svg con la visualización
svg = d3.select('svg'), // elemento del DOM del svg
lineCorrectionFactor = .3, // corrige la longitud de las líneas para evitar huecos entre ellas
pointThickness = 6, // grosor de los puntos (per cápita)
lineWidth = 2.5, // grosor de las líneas (per cápita)
minX = 1960, // mínimo valor del eje x
maxX = 2016, // máximo valor del eje x
numOfCountries = 8, // número de países de la visualización
lineAnimationDuration = 10,
scaleX = d3.scaleLinear() // escala del eje x (es siempre la misma)
.domain([minX, maxX])
.range([hPadding, svgWidth - hPadding]),
ylabel = svg.append("text")
.attr("transform", "rotate(-90)")
.attr("y", 0)
.attr("x", 0 - (svgHeight / 2))
.attr('class', 'ylabel')
.attr("dy", "1em")
.style("text-anchor", "middle")
.style('font-size', '18px');
let perCapita = true, // guarda el modo de visualización actual
first = true, // se utiliza para trazar una sola vez el eje x y la línea del P. Kyoto
scaleY = undefined, // mantiene la escala del eje Y para utilizarla 'fuera'
pointsPerCountry = undefined; // guarda la estructura de datos que mantiene los puntos de los polígonos
// de los distintos países
const countriesHidden = Object.assign({}, colors); // guarda qué países están ocultos en la visualización
for (country in countriesHidden) countriesHidden[country] = false; // En un principio todos visibles (hidden = false)
// Constantes de la interfaz
// Elementos del DOM que se modifican dinámicamente: overlay(datos de los puntos), slider, leyenda ...
const overlayInfo = document.querySelector('#overlay-info'),
countryDataOverlay = document.querySelector('#country-data'),
perCapitaDataOverlay = document.querySelector('#per-cap-data'),
yearDataOverlay = document.querySelector('#year-data'),
currentDate = document.querySelector('#current-date'),
dateLimiter = document.querySelector('#date-limiter'),
legend = document.querySelector('#legend'),
slider = document.querySelector('#slider'),
arranger = slider.children[0],
progress = slider.children[1];
// Inicializar leyenda
// Crea la leyenda dinámicamente con el mapeo de colores, el estilo se ha creado en el css
let hijoN = 0;
for (country in colors) {
const newItem =
`<div class="legend-item">
<div class="item-square">
</div>
<div class="item-text" countryAssociated="${country}">
${country}
</div>
</div>`;
legend.innerHTML += newItem;
const currentChild = legend.children[hijoN++]
const childSquare = currentChild.children[0];
childSquare.style.background = colors[country];
}
// Cambio entre visualizaciones
perCapitaVisualization(); // Empezamos con la per cápita
//Manejar cambios a la visualización de emisiones total
document.querySelector('#total').addEventListener('change', function(){
// Los radio buttons son personalizados por lo que se utiliza esto para darles estilo
// distinto cuando están seleccionados
document.querySelector('[for="capita"]').classList.remove('marked');
document.querySelector('[for="total"]').classList.add('marked');
// Animación de líneas para el cambio
d3.selectAll('.perCapitaLine')
.transition()
.duration(lineAnimationDuration)
.delay(delayLines)
.attr('x1', function(){ return d3.select(this).attr('x2') })
.attr('y1', function(){ return d3.select(this).attr('y2') })
// Cuando termina la animación (timeout para evitar cortarla) se realizan ajustes
// para el cambio y se cambia de visualización
setTimeout(function(){
// Evita errores con el overlay
overlayInfo.style.display = 'none';
// Ocultar el slider y recolocarlo cuando está oculto para el próximo cambio
dateLimiter.style.opacity = 0;
moveArranger(document.body.offsetWidth);
currentDate.innerHTML = maxX;
// Elimina las líneas para ahorrar espacio del árbol DOM para ganar eficiencia
d3.selectAll('.perCapitaLine,circle').remove();
perCapita = !perCapita
totalVisualization();
}, lineAnimationDuration * (maxX - minX) + 200);
});
//Manejar cambios a la visualización de emisiones per cápita
document.querySelector('#capita').addEventListener('change', function(){
// Estilo de los radio buttons (se le quita a uno y se le pone al otro por eso se 'repite')
document.querySelector('[for="total"]').classList.remove('marked');
document.querySelector('[for="capita"]').classList.add('marked');
// Animación de cierre de visualización total
d3.selectAll('polygon')
.transition()
.duration(50)
.delay((d,i) => 50*i)
.attr('points', '')
d3.selectAll('.worldLine')
.transition()
.duration(500)
.attr('opacity', 0)
// De nuevo timeout para evitar cortar la animación
setTimeout(function () {
// Recuperamos el slider
dateLimiter.style.opacity = 1;
// Eliminamos polígonos por cuestión de eficiencia
// (visualmente han desaparecido al igual que las líneas)
d3.selectAll('.worldLine').remove();
d3.selectAll('polygon').remove();
perCapita = !perCapita
perCapitaVisualization();
}, 100*numOfCountries);
});
// Visualización emisiones per cápita
// Función que representa la parte de las emisiones per cápita
function perCapitaVisualization(){
d3.csv('per-capita-emissions-filtered.csv')
.then(function (data) {
const minY = d3.min(data.map(d => Number(d.pcapEmissions)));
const maxY = d3.max(data.map(d => Number(d.pcapEmissions)));
// Se genera la escala del eje y (cambia entre las dos partes)
scaleY = d3.scaleLinear()
.domain([minY, maxY])
.range([svgHeight-vPadding, vPadding]);
// Se crea el eje y se llama (modificando parámetros, ticks que aparecen ...)
const yAxis = d3.axisLeft().scale(scaleY)
.ticks(0)
.tickSize(0);
svg.append('g')
.attr('transform', `translate(${hPadding}, 0)`)
.call(yAxis);
// Etiqueta del eje y (texto)
ylabel.text("CO2 (Toneladas)");
// Crea el eje X y la línea del protocolo de Kyoto, son comunes a ambas partes por lo que
// se genera una única vez
if(first){
const xAxis = d3.axisBottom().scale(scaleX)
.ticks(5)
.tickFormat(d3.format('.0f'))
.tickSizeOuter(0);
svg.append('g')
.attr('transform', `translate(0,${svgHeight - vPadding})`)
.call(xAxis);
d3.selectAll('.tick text') // Desplaza hacia abajo el texto de los ticks
.attr('y', 16)
const kyotoX = scaleX(1997),
kyotoColor = '#B3A6A6';
svg.append('line')
.attr('x1', kyotoX)
.attr('x2', kyotoX)
.attr('y1', scaleY(minY))
.attr('y2', scaleY(maxY + 1))
.style('stroke', kyotoColor)
.style('stroke-width', lineWidth)
.style('stroke-dasharray', '10');
svg.append('text')
.attr('x', kyotoX + 15)
.attr('y', scaleY(maxY) - 10)
.text('Protocolo de Kyoto')
.style('font-size', '18px')
.style('fill', kyotoColor)
first = !first
}
// Se crean
const initialX = d => scaleX(Number(d.Year));
const initialY = d => scaleY(Number(d.pcapEmissions));
// Se generan las líneas de la visualización per cápita
// con su animación de aparición, si el país no se mostraba en la parte anterior,
// se sigue sin mostrar
svg.selectAll('.perCapitaLine')
.data(data)
.enter()
.append('line')
.attr('x1', initialX)
.attr('x2', initialX)
.attr('y1', initialY)
.attr('y2', initialY)
.attr('country', d => d.Entity)
.attr('year', d => Number(d.Year) + 1)
.attr('class', 'perCapitaLine')
.attr('visibility', d => countriesHidden[d.Entity] ? 'hidden' : 'visible')
.style('stroke', d => colors[d.Entity])
.style('stroke-width', lineWidth)
.transition()
.duration(lineAnimationDuration)
.delay(delayLines)
.attr('x2', (d, i) => {
const finalX2 = d.Year !== '2016' ? Number(data[i + 1].Year) : Number(d.Year);
return scaleX(finalX2) + lineCorrectionFactor;
})
.attr('y2', (d, i) => {
const finalY2 = d.Year !== '2016' ? Number(data[i + 1].pcapEmissions) : Number(d.pcapEmissions);
return scaleY(finalY2);
})
// Se generan los puntos de la visualización per cápita
// que se utilizan como indicador visual de sobre qué punto se está mostrando información (overlay),
// además se asocia también el evento de hovering en su creación
svg.selectAll('circle')
.data(data)
.enter()
.append('circle')
.attr("r", pointThickness)
.attr("cx", d => scaleX(Number(d.Year)))
.attr("cy", d => scaleY(Number(d.pcapEmissions)))
.attr('country', d => d.Entity)
.attr('year', d => d.Year)
.attr('per-cap', d => d.pcapEmissions)
.attr('visibility', d => countriesHidden[d.Entity] ? 'hidden' : 'visible')
.style('fill', 'transparent')
.style('cursor', 'pointer')
.on('mouseover', function(){ // Mostrar el overlay y el punto (actualizando información)
// Se utiliza un solo overlay que se oculta y reposiciona por cuestiones de eficiencia
// del árbol DOM
const thisCircle = d3.select(this);
const overlayYCorrection = 40;
const overlayXCorrection = 280;
thisCircle.style('fill', d => colors[d.Entity]);
overlayInfo.style.display = 'block';
overlayInfo.style.top = Number(thisCircle.attr('cy')) + overlayYCorrection + 'px';
overlayInfo.style.left = Number(thisCircle.attr('cx')) + overlayXCorrection + 'px';
countryDataOverlay.innerHTML = thisCircle.attr('country');
perCapitaDataOverlay.innerHTML = thisCircle.attr('per-cap');
yearDataOverlay.innerHTML = thisCircle.attr('year');
})
.on('mouseout', function () { // Ocultar el overlay y el punto
d3.select(this).style('fill', 'transparent')
overlayInfo.style.display = 'none';
});
});
}
// Visualización emisiones totales
function totalVisualization(){
d3.csv('total-emissions-filtered.csv')
.then(function (data) {
const minY = d3.min(data.map(d => Number(d.totalEmissions)));
const maxY = d3.max(data.map(d => Number(d.totalEmissions)));
// Se cambia la escala del eje Y (distinta en las dos partes)
scaleY = d3.scaleLinear()
.domain([minY, maxY])
.range([svgHeight - vPadding, vPadding]);
// Se crea y llama el nuevo eje y
const yAxis = d3.axisLeft().scale(scaleY)
.ticks(0)
.tickSize(0);
svg.append('g')
.attr('transform', `translate(${hPadding}, 0)`)
.call(yAxis);
// Etiqueta del eje y (texto)
ylabel.text("CO2 (Mill. Toneladas)");
const dataPerCountry = Object.assign({}, colors); // Agrupa los datos por países(comodidad)
// Inicialmente cada coloumna se encuentra en un objeto separado
// Crea un array con 57 ceros (numero de datos por país) que acumula las emisiones
let previousCountryValues = [...Array(57).keys()].map(() => 0);
pointsPerCountry = []; // Estructura de datos útil para manejar el polígono
for(country in dataPerCountry) {
dataPerCountry[country] = data.filter(d => d.Entity === country) // Va agrupando los datos por países
pointsPerCountry.push(
{
totalEmissions: dataPerCountry[country].map(d => Number(d.totalEmissions)), // Emisiones de cada año
base: previousCountryValues.map(d => d), // base del polígono, final del 'techo' del anterio
years: dataPerCountry[country].map(d => Number(d.Year)), // Año de cada punto
country
}
);
// Acumula los valores de emisiones (los áreas son acumulados) para asignarlos a la base de los polígonos
// Si un páis está oculto no se suma al acumulado
previousCountryValues = previousCountryValues.map((val, indx) => {
const totalEmissionsThatYear = Number(dataPerCountry[country][indx].totalEmissions);
return val + (countriesHidden[country] ? 0 : totalEmissionsThatYear)
});
}
// Se crean los poligonos de la visualización (si no se han eliminado de la misma)
// con su correspondiente animación de aparición las bases se invierten porque svg
// utiliza el convexo de hull de los vértices para generar polígonos (orden de reloj de los vértices)
svg.selectAll('polygon')
.data(reverseArray(pointsPerCountry))
.enter()
.append('polygon')
.attr('points', d => {
const basePoints = d.years.map(year => [year, 0]);
return pointsFormatting(basePoints,reverseArray(basePoints));
})
.attr('fill', d => colors[d.country])
.attr('country', d => d.country)
.attr("stroke", "black")
.attr("stroke-width", .8)
.attr('visibility', d => countriesHidden[d.country] ? 'hidden' : 'visible')
.transition()
.duration(1000)
.delay((d, i) => (numOfCountries-i) * 100)
.attr('points', d => {
const basePoints = d.base.map((baseY, i) => [d.years[i], baseY]);
const topPoints = d.base.map((baseY, i) => [d.years[i], baseY + d.totalEmissions[i]]);
if(!countriesHidden[d.country]) return pointsFormatting(topPoints, basePoints.reverse());
else return pointsFormatting(basePoints,reverseArray(basePoints));
})
// filtrado de los datos (cogiendo los correspondientes a datos mundiales)
const worldData = data.filter(d => d.Entity == 'World');
// Trazado de la línea de emisiones totales mundiales
svg.selectAll('.worldLine')
.data(worldData)
.enter()
.append('line')
.attr('x1', d => scaleX(Number(d.Year)))
.attr('x2', (d,i) => d.Year !== '2016' ? scaleX(Number(worldData[i + 1].Year)) + lineCorrectionFactor : scaleX(Number(d.Year)))
.attr('y1', d => scaleY(Number(d.totalEmissions)))
.attr('y2', (d,i) => d.Year !== '2016' ? scaleY(Number(worldData[i + 1].totalEmissions)) : scaleY(Number(d.totalEmissions)))
.attr('class', 'worldLine')
.attr('stroke-width', 3)
.attr('stroke', 'gray')
.attr('opacity', 0)
// Texto de la línea de emisiones totales mundiales
svg.append('text')
.attr('x', scaleX(Number(worldData[worldData.length - 1].Year)) - 70)
.attr('y', scaleY(Number(worldData[worldData.length - 1].totalEmissions))-10)
.text('Mundial')
.attr('class', 'worldLine')
.attr('fill', 'gray')
.attr('opacity', 0)
// Animación de la línea y el texto de emisiones totales mundiales (aparición progresiva)
d3.selectAll('.worldLine')
.transition()
.duration(2000)
.attr('opacity', 1)
})
}
// Interacción
document.querySelectorAll('.item-text').forEach(function (item) { // Se añaden eventos a todos los items de la leyenda
// Maneja el evento de interacción de que al hacer click sobre un elemento de la leyenda
// el país se elimina de la visualización o se añade si estaba eliminado
item.addEventListener('click', function(e) {
const legendItem = e.target; // elemento de la leyenda asociado (el que ha generado el evento)
const countryAssociated = legendItem.getAttribute('countryAssociated'); // País del elemento
const lineSelected = d3.selectAll(`[country="${countryAssociated}"]:not(polygon)`); // Elementos de la visualización del país
// Lo copio ya que necesito cambiar el estado antes de llamar a la estructura de control
// ya que necesito la estructura de datos actualizada para cambiar las áreas
// y evito repetir código (podría meterlo dos veces antes de llamar a hideShowCountryArea)
const thisCountryIsHidden = countriesHidden[countryAssociated];
//Si el país estaba oculto en la estructura de datos ahora no lo está y viceversa.
countriesHidden[countryAssociated] = !countriesHidden[countryAssociated];
// Si el país estaba eliminado/oculto se devuelve a la visualización y se actualiza el elemento de la leyenda (no tachado)
// Si no se tacha y se elimina de la visualización
if (thisCountryIsHidden) {
lineSelected.attr('visibility', function () {
const thisLine = d3.select(this),
thisLineYear = Number(thisLine.attr('year')),
maxYear = Number(currentDate.innerHTML);
// Mantiene la coherencia si la fecha está limitada
if (thisLineYear > maxYear) {
return 'hidden';
}
else {
return 'visible';
}
});
legendItem.style.textDecoration = 'none';
if(!perCapita) hideShowCountryArea(countryAssociated, show=true) //Los polígonos se eliminan de distinta manera
}
else {
if(!perCapita) hideShowCountryArea(countryAssociated) //Los polígonos se eliminan de distinta manera
lineSelected.attr('visibility', 'hidden');
legendItem.style.textDecoration = 'line-through';
resetOpacity();
}
});
// Maneja el evento de que al entrar con el ratón en el elemento de la leyenda
// asociado a un país, el resto países tienen un alfa menor en la visualización (destaca más el deseado)
item.addEventListener('mouseenter', function (e) {
const legendItem = e.target; // Elemento del DOM del elemento de la leyenda
const countryAssociated = legendItem.getAttribute('countryAssociated'); // Obtiene el país asociado a ese elemento de la leyenda
// Bucle que disminuye el alfa del resto de países, si el país se ha eliminado de la visualización
// no se efectúa esta operación
if(countriesHidden[countryAssociated] !== true){
for(country in colors){
const countryElements = d3.selectAll(`[country="${country}"]`);
if(country !== countryAssociated && perCapita) countryElements.style('opacity',.2)
else if(country !== countryAssociated) countryElements.style('opacity',.2)
}
}
});
// Los elementos de la visualización de todos los países vuelven a su alfa normal al dejar de hacer hover
item.addEventListener('mouseout', function() {
resetOpacity();
});
})
// Función que devuelve el alfa normal a todos los elementos de la visualización
function resetOpacity() {
for (country in colors) d3.selectAll(`[country="${country}"]`)
.style('opacity', 1)
.style('opacity', 1);
}
//
let mouseHold = false;
// Se pulsa el ratón en el slider
slider.addEventListener('mousedown', () => mouseHold = true);
//Se suelta el ratón, no importa el lugar dónde se suelte dado que se permite desplazamiento no lineal,
//es decir, se suelta dentro de la página (el documento)
document.addEventListener('mouseup', () => mouseHold = false);
// Se mueve el ratón por el documento, el manejador del evento comprobará si se ha pulsado el ratón
// sobre el slider y sigue mantenido
document.addEventListener('mousemove', handleArrangerMovement);
// Se hace click sobre un punto del slider (evita la necesidad de arrastrar)
slider.addEventListener('mousedown', handleArrangerMovement);
// Manejador del arrastrado del slider
function handleArrangerMovement(event) {
// Obtiene el tamaño del slider, su posición horizontal(x), la mitad de la anchura del 'agarrador' (ya que se
// utiliza el punto medio del mismo como valor del limitador de fechas) y la nueva posición requerida por el mouse
// de manera dinámica
const bounds = slider.getBoundingClientRect(),
x = bounds.left,
halfArrangerWidth = arranger.offsetWidth / 2,
newPos = event.clientX - x - halfArrangerWidth;
// Si el ratón ha sido pulsado en el slider y mantenido está, nueva posición se calcula y se limita el valor de la
// fecha en el gráfico per cápita, dejando de visualizar las líneas posteriores a esa feha
if(mouseHold) {
const barPercentage = moveArranger(newPos),
newCurrentDate = Math.round(barPercentage * (maxX - minX) + minX);
currentDate.innerHTML = newCurrentDate;
d3.selectAll('line, circle')
.attr('visibility', function(){
const thisLine = d3.select(this),
thisLineYear = Number(thisLine.attr('year')),
thisLineCountry = thisLine.attr('country');
if(thisLineYear <= newCurrentDate && !countriesHidden[thisLineCountry]) return 'visible';
return 'hidden';
})
}
}
// Funciones auxiliares
// Función que dada una nueva posición del agarrador (la cual puede no ser válida), es decir, mayor o menor que los límites del slider,
// la transforma (si es mayor el máximo del slider, si es menor el mínimo del slider y si es válida pues la requerida).
// Finalmente efectúa el movimiento de los componentes de la interfaz (agarrador y progreso), y calcula y devuelve el porcentaje de la
// barra en el que se encuetra el agarrador, que se puede utilizar en el resto de funciones para calcular el año limitado p.ej
function moveArranger(newPosition) {
const width = slider.offsetWidth,
halfArrangerWidth = arranger.offsetWidth / 2,
newPositionMedium = newPosition + halfArrangerWidth;
let finalPosition;
if (newPositionMedium > width) {
finalPosition = width - halfArrangerWidth;
}
else if (newPositionMedium < 0) finalPosition = - halfArrangerWidth;
else finalPosition = newPosition;
console.log(finalPosition);
arranger.style.left = finalPosition + 'px'
progress.style.width = (finalPosition >= 0 ? finalPosition : 0) + 'px';
const barPercentage = (finalPosition + halfArrangerWidth) / width;
return barPercentage;
}
// Función que toma dos arrays de puntos en formato [[x1,y1],[x2,y2],[x3,y3], ...] y [[x7,y7],[x8,y8],[x9,y9], ...] y los concatena
// y transforma en puntos con el formato "x1,y1 x2,y2 x3,y3 x7,y7 x8,y8 x9,y9" que es el que utiliza d3 para polígonos (realmente svg)
function pointsFormatting(pointsArray1, pointsArray2) {
const pointArr1Formatted = pointsArray1.map(point => [scaleX(point[0]), scaleY(point[1])]);
const pointArr2Formatted = pointsArray2.map(point => [scaleX(point[0]), scaleY(point[1])]);
return pointArr1Formatted.concat(pointArr2Formatted);
}
// Invierte un array sin modificar el original (arr.reverse() modifica arr)
const reverseArray = arr => arr.slice().reverse();
// Función que calcula el delay de cada una de las líneas (tanto en la animación de aparición como en la de desaparición),
// cuánto más nuevas más delay.
function delayLines() {
return (Number(d3.select(this).attr('year')) - (minX + 1)) * lineAnimationDuration
}
// Función que se encarga de ocultar y mostrar países en el Stacked Area Chart en consecuencia a las acciones del usuario
// en la leyenda. Si se oculta un país, resta sus emisiones al resto de áreas (a las bases del polígono)
// que se encuentran por encima de la de éste país (disminuye el acumulado). Si se muestra un país se suman a las bases
// de los polígonos superiores. Finalmente actualiza estos cambios en la visualización.
function hideShowCountryArea(countryModified, show = false) {
let i = 0;
for(; pointsPerCountry[i].country !== countryModified; i++);
const indexOfModified = i;
for(; i < pointsPerCountry.length; i++){
const countryRepositionated = pointsPerCountry[i].country
if(countryRepositionated !== countryModified){
pointsPerCountry[i].base = pointsPerCountry[i].base.map((b, j) => {
if (show) return b + pointsPerCountry[indexOfModified].totalEmissions[j];
else return b - pointsPerCountry[indexOfModified].totalEmissions[j];
})
}
d3.select(`polygon[country="${countryRepositionated}"]`)
.transition()
.duration(1000)
.attr('points', function() {
if (countryRepositionated === countryModified && !countriesHidden[countryModified]) {
d3.select(this).attr('visibility', 'visible');
}
const basePoints = pointsPerCountry[i].base.map((baseY, j) => {
const currentYear = pointsPerCountry[i].years[j];
return [currentYear, baseY];
});
const topPoints = pointsPerCountry[i].base.map((baseY, j) => {
const currentYear = pointsPerCountry[i].years[j];
let currentYearEmissions = pointsPerCountry[i].totalEmissions[j];
currentYearEmissions = countriesHidden[countryRepositionated] ? 0 : currentYearEmissions;
return [currentYear, baseY + currentYearEmissions];
});
return pointsFormatting(topPoints, basePoints.reverse());
})
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment