En la versión anterior de mi gráfica realizada usando las librerías D3.js añadimos una nueva gráfica a la gráfica principal. Esto, nos sirvió como excusa para practicar con un método fundamental de las librerías D3.js: el método d3.data
.
El resultado final, sin embargo, no me gustaba demasiado: no lograba representar lo suficientemente claro lo que pretendía: detectar las zonas en las que se habían cumplido las condiciones de revalorización introducidas. A partir de este momento, es cuando interviene la creatividad para conseguir visualizar los datos de una forma más efectiva. Los límites, no los pone la librería D3.js, los límites los pones tú.
Debo reconocer que la parte creativa no es precisamente mi punto fuerte (igual me pegué un golpe de crío en la parte derecha de la cabeza). Pero, de todos modos, hago lo que puedo. Esta vez, voy a sustituir la gráfica secundaria por una nueva versión mediante colores:
- Si el punto cumplió las condiciones de revalorización en el plazo pedido, aparece tendiendo al color RGB(205,235,139).
- Si consiguió la revaloración pero fuera de plazo, aparece tendiendo al color RGB(252,63,30).
- Y si no consigue la revalorización, es marcado de color RGB(246,246,246).
A ver si os gusta más (y sobre todo, os gusta el método para programar esta nueva variante).
El resultado final
El resultado final es el siguiente:
Con los datos por defecto (revalorización del 4% y un plazo de 730 días) casi todas las áreas aparecen marcadas en verde (si se hubiera entrado en esos momentos, se hubiera conseguido la rentabilidad del 4% en ese plazo), hay algunas áreas marcadas en rojo (se consiguió la revalorización, pero se tardó más de ese límite de tiempo) y hay algunas áreas (finales del año 2007 y finales del 2009) que resaltan en blanco (que todavía no se ha conseguido esa revalorización).
Podéis abrir este mismo resultado en una ventana independiente aquí.
El código html
El código html continúa sigue siendo el mismo que en la versión anterior, así que no lo repito.
El código Javascript
El código Javascript completo es el siguiente (por motivos didácticos, veréis que he dejado el código correspondiente a la versión anterior de la gráfica secundaria, comentado):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 |
'use strict'; (function(d3) { jQuery(function() { var data = []; var minDate, maxDate, maxValue; var fromDate, toDate; var width, height; var margins = { top: 20, right: 50, bottom: 30, left: 50 }; var millisecondsToDays = (1000 * 60 * 60 * 24); var returnRate = 4; var daysLimit = 365 * 2; var returnRateLabel = jQuery('#returnRateValue'); var daysLimitLabel = jQuery('#daysLimitValue'); var scrollSlider = new window.Slider('#scroll', {}); scrollSlider.setAttribute('formatter', function(value) { var resp; if (Array.isArray(value)) { resp = []; jQuery.each(value, function(i,v) { var d = new Date(0); d.setUTCMilliseconds(minDate.valueOf() + v * millisecondsToDays); resp.push(d.toLocaleDateString()); }); resp = resp.join(' to '); } else { var d = new Date(0); d.setUTCMilliseconds(minDate.valueOf() + value * millisecondsToDays); resp = d.toLocaleDateString(); } return resp; }); scrollSlider.on('slideStop', function() { redrawData(); redrawNumDays(false); }); jQuery('#returnRate') .attr('data-slider-value', parseInt(returnRate)) .slider() .on('slideStop', function(evt) { returnRate = parseInt(evt.value); redrawNumDays(); }) .on('change', function(evt) { returnRateLabel.text(evt.value.newValue + '%'); }); returnRateLabel.text(returnRate + '%'); jQuery('#daysLimit') .attr('data-slider-value', parseInt(daysLimit)) .slider() .on('slideStop', function(evt) { daysLimit = parseInt(evt.value); redrawNumDays(); }) .on('change', function(evt) { daysLimitLabel.text(evt.value.newValue + ' days'); }); daysLimitLabel.text(daysLimit + ' days'); var svg = d3.selectAll('div#ibex35').append('svg'); svg.append('svg:g').attr('class', 'x axis'); svg.append('svg:g').attr('class', 'y axis left'); //svg.append('svg:g').attr('class', 'y axis right'); svg.append('svg:g').attr('class', 'return-days'); //svg.append('svg:path').attr('class', 'return-days'); svg.append('svg:path').attr('class', 'daily-values'); var xRange = d3.time.scale(); var yRangeValues = d3.scale.linear(); //var yRangeDays = d3.scale.linear(); var xAxis = d3.svg.axis() .scale(xRange) .tickSize(5) .tickSubdivide(true); var yAxisValues = d3.svg.axis() .scale(yRangeValues) .tickSize(5) .orient('left') .tickSubdivide(true); //var yAxisDays = d3.svg.axis() //.scale(yRangeDays) //.tickSize(5) //.orient('right') //.tickSubdivide(true); var valueFunc = d3.svg.line() .x(function(d) { return xRange(d.date); }) .y(function(d) { return yRangeValues(d.high); }) .interpolate('linear'); //var dayFunc = d3.svg.line() //.x(function(d) { return xRange(d.date); }) //.y(function(d) { return yRangeDays(d.days); }) //.interpolate('linear'); var popupArea = svg.append('svg:g').attr('class', 'popup-area'); var mouseOverArea = svg.append('svg:rect') .attr('x', margins.left) .attr('y', margins.top) .attr('class', 'mouseover-area'); mouseOverArea.on('mouseout', function() { popupArea.selectAll('*').remove(); }); mouseOverArea.on('mousemove', function() { var posX = d3.mouse(this)[0]; var date = xRange.invert(posX); for (var i=1, point=null; i<data.length && point===null; i++) { if (data[i-1].date <= date && data[i].date >= date) { point = data[(Math.abs(data[i-1].date - date) > Math.abs(data[i].date - date)) ? i-1 : i]; } } popupArea.selectAll('*').remove(); if (point !== null) { var popupX = xRange(point.date) + 5; var popupY = yRangeValues(point.high); var popupWidth = 100; var popupHeight = 90; var valueFormatter = d3.format('^$,.2f'); if (popupX + popupWidth > width - margins.right) { popupX = popupX - popupWidth - 10; } var popup = popupArea.append('svg:g') .attr('class', 'popup-text') .attr('transform', 'translate(' + popupX + ',' + popupY +')'); popup.append('svg:rect') .attr('x', 0) .attr('y', 0) .attr('width', popupWidth) .attr('height', popupHeight); popup.append('svg:text') .attr('x', popupWidth / 2) .attr('y', 16) .text(point.date.toLocaleDateString()) .attr('class', 'point-date'); popup.append('svg:text') .attr('x', popupWidth / 2) .attr('y', 29) .text(valueFormatter(point.high).replace(/^\$/, '€').replace(/,/, '.').replace(/\.(\d\d)$/, ',$1')) .attr('class', 'point-value'); if (point.returnPoint !== null) { popup.append('svg:text') .attr('x', popupWidth / 2) .attr('y', 45) .text(point.returnPoint.date.toLocaleDateString()) .attr('class', 'point-date'); popup.append('svg:text') .attr('x', popupWidth / 2) .attr('y', 58) .text(valueFormatter(point.returnPoint.high).replace(/^\$/, '€').replace(/,/, '.').replace(/\.(\d\d)$/, ',$1')) .attr('class', 'point-value'); popup.append('svg:text') .attr('x', popupWidth / 2) .attr('y', 75) .text(point.days + ' days') .attr('class', 'point-value'); //popupArea.append('circle') //.attr('cx', xRange(point.date)) //.attr('cy', yRangeDays(point.days)) //.attr('r', 3) //.attr('class', 'popup-circle'); } popupArea.append('circle') .attr('cx', xRange(point.date)) .attr('cy', yRangeValues(point.high)) .attr('r', 3) .attr('class', 'popup-circle'); } }); var redrawSvg = function redrawSvg() { jQuery('#ibex35').css('position', 'absolute'); width = parseInt(jQuery('#ibex35').css('width'))-20; height = parseInt(jQuery('#wrapper').css('height')) - parseInt(jQuery('#graph-scroll').css('height')) - parseInt(jQuery('#graph-options').css('height')); jQuery('#ibex35').css('position', 'relative'); svg.attr('width', width).attr('height', height); mouseOverArea.attr('width', width - margins.left - margins.right) .attr('height', height - margins.top - margins.bottom); svg.select('g.x.axis').attr('transform', 'translate(0,' + (height - margins.bottom) + ')'); svg.select('g.y.axis.left').attr('transform', 'translate(' + (margins.left) + ',0)'); svg.select('g.y.axis.right').attr('transform', 'translate(' + (width - margins.right) + ',0)'); xRange.range([margins.left, width - margins.right]); yRangeValues.range([height - margins.bottom, margins.top]); //yRangeDays.range([height - margins.bottom, margins.top]); }; var redrawData = function redrawData() { var values = scrollSlider.getValue(); fromDate = new Date(minDate.valueOf() + values[0] * millisecondsToDays); toDate = new Date(minDate.valueOf() + values[1] * millisecondsToDays); var filteredData = data.filter(function(d) { return (d.date >= fromDate && d.date <= toDate); }); svg.data([filteredData]); xRange.domain([fromDate, toDate]); yRangeValues.domain([0, maxValue]); svg.select('g.x.axis').transition().call(xAxis); svg.select('g.y.axis.left').transition().call(yAxisValues); svg.select('path.daily-values').transition().attr('d', function(d) { return valueFunc(d); }); }; var calcReturnDays = function() { for (var i=0; i<data.length; i++) { var returnValue = data[i].high * (1 + returnRate / 100); data[i].days = null; data[i].returnPoint = null; for (var j=i+1; j<data.length && data[i].days===null; j++) { if (data[j].high >= returnValue) { data[i].days = Math.min(j-i+1, daysLimit); data[i].returnPoint = data[j]; } } } }; var redrawNumDays = function(recalc) { recalc = (recalc === undefined || true); if (recalc) { calcReturnDays(); } //yRangeDays.domain([0, parseInt(daysLimit)]); //svg.select('g.y.axis.right').transition().call(yAxisDays); var slotWidth = xRange(new Date(fromDate.valueOf() + 1 * millisecondsToDays)) - xRange(fromDate); var width = 0; if (slotWidth >= 5) { width = Math.min(2, parseInt(slotWidth / 2)); } var calcColor = function(d) { var r, g, b; if (d.days === null) { r = 246; g = 246; b = 246; } else { r = 205 + Math.round(((252 - 205) / daysLimit * Math.min(daysLimit, d.days))); g = 235 + Math.round(((63 - 235) / daysLimit * Math.min(daysLimit, d.days))); b = 139 + Math.round(((30 - 139) / daysLimit * Math.min(daysLimit, d.days))); } return 'fill:rgba(' + r + ',' + g + ',' + b + ',' + '0.5' + ');'; }; var rects = svg.select('g.return-days').selectAll('rect').data(function(d) { return d; }); rects.enter().append('rect'); rects.attr('x', function(d) { return xRange(d.date) - width; }) .attr('y', margins.top) .attr('height', height - margins.bottom - margins.top) .attr('width', function() { return 2 * width + 1; }) .attr('style', function(d) { return calcColor(d); }); rects.exit().remove(); //svg.select('path.return-days').transition().attr('d', function(d) { return dayFunc(d); }); }; redrawSvg(); d3.csv('./data/YAHOO-IBEX.csv') .row(function(d) { return { date: new Date(d.Date), high: +d.High }; }) .get(function(error, rows) { data = rows; fromDate = minDate = d3.min(data, function(d) { return d.date; }); toDate = maxDate = d3.max(data, function(d) { return d.date; }); maxValue = d3.max(data, function(d) { return d.high; }); var numDays = (maxDate - minDate) / millisecondsToDays; scrollSlider.setAttribute('min', 0); scrollSlider.setAttribute('max', numDays); scrollSlider.setValue([0, numDays]); redrawData(); redrawNumDays(); }); jQuery(window).resize(function() { redrawSvg(); redrawData(); redrawNumDays(); }); }); })(window.d3); |
Vamos a comenzar a estudiarlo, poco a poco. En las explicaciones, nos centraremos en las diferencias con la versión anterior, para comprender cómo la hemos modificado para introducir la nueva representación.
Definición de variables globales
Las variables globales que hemos necesitado, son las misma que la versión anterior:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
(function(d3) { jQuery(function() { var data = []; var minDate, maxDate, maxValue; var fromDate, toDate; var width, height; var margins = { top: 20, right: 50, bottom: 30, left: 50 }; var millisecondsToDays = (1000 * 60 * 60 * 24); var returnRate = 4; var daysLimit = 365 * 2; var returnRateLabel = jQuery('#returnRateValue'); var daysLimitLabel = jQuery('#daysLimitValue'); |
Definición de los sliders
La definición de los sliders, también es igual:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
var scrollSlider = new window.Slider('#scroll', {}); scrollSlider.setAttribute('formatter', function(value) { var resp; if (Array.isArray(value)) { resp = []; jQuery.each(value, function(i,v) { var d = new Date(0); d.setUTCMilliseconds(minDate.valueOf() + v * millisecondsToDays); resp.push(d.toLocaleDateString()); }); resp = resp.join(' to '); } else { var d = new Date(0); d.setUTCMilliseconds(minDate.valueOf() + value * millisecondsToDays); resp = d.toLocaleDateString(); } return resp; }); scrollSlider.on('slideStop', function() { redrawData(); redrawNumDays(false); }); jQuery('#returnRate') .attr('data-slider-value', parseInt(returnRate)) .slider() .on('slideStop', function(evt) { returnRate = parseInt(evt.value); redrawNumDays(); }) .on('change', function(evt) { returnRateLabel.text(evt.value.newValue + '%'); }); returnRateLabel.text(returnRate + '%'); jQuery('#daysLimit') .attr('data-slider-value', parseInt(daysLimit)) .slider() .on('slideStop', function(evt) { daysLimit = parseInt(evt.value); redrawNumDays(); }) .on('change', function(evt) { daysLimitLabel.text(evt.value.newValue + ' days'); }); daysLimitLabel.text(daysLimit + ' days'); |
Definición de los elementos de la gráfica
Los elementos SVG que hemos definido son los mismos, excepto que hemos añadido un nuevo elemento g
de la clase return-days
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
var svg = d3.selectAll('div#ibex35').append('svg'); svg.append('svg:g').attr('class', 'x axis'); svg.append('svg:g').attr('class', 'y axis left'); //svg.append('svg:g').attr('class', 'y axis right'); svg.append('svg:g').attr('class', 'return-days'); //svg.append('svg:path').attr('class', 'return-days'); svg.append('svg:path').attr('class', 'daily-values'); var xRange = d3.time.scale(); var yRangeValues = d3.scale.linear(); //var yRangeDays = d3.scale.linear(); var xAxis = d3.svg.axis() .scale(xRange) .tickSize(5) .tickSubdivide(true); var yAxisValues = d3.svg.axis() .scale(yRangeValues) .tickSize(5) .orient('left') .tickSubdivide(true); //var yAxisDays = d3.svg.axis() // .scale(yRangeDays) // .tickSize(5) // .orient('right') // .tickSubdivide(true); var valueFunc = d3.svg.line() .x(function(d) { return xRange(d.date); }) .y(function(d) { return yRangeValues(d.high); }) .interpolate('linear'); //var dayFunc = d3.svg.line() // .x(function(d) { return xRange(d.date); }) // .y(function(d) { return yRangeDays(d.days); }) // .interpolate('linear'); var popupArea = svg.append('svg:g').attr('class', 'popup-area'); |
Más adelante, veremos cómo lo usamos para crear nuestra nueva gráfica. Es importante tener en cuenta el detalle de que lo creo antes que el elemento path
de clase daily-values
(que es el que contiene mi gráfica principal) para que mi gráfica secundaria se visualice por detrás de mi gráfica principal.
Veréis que están comentados los elementos SVG y los objetos D3.js que necesitaba para la versión anterior de la gráfica secundaria, pero que ya no necesito para mi nueva versión.
Definición del popup
La definición del popup tampoco ha variado (excepto que he quitado el circulito con el que marcaba la gráfica secundaria):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
var mouseOverArea = svg.append('svg:rect') .attr('x', margins.left) .attr('y', margins.top) .attr('class', 'mouseover-area'); mouseOverArea.on('mouseout', function() { popupArea.selectAll('*').remove(); }); mouseOverArea.on('mousemove', function() { var posX = d3.mouse(this)[0]; var date = xRange.invert(posX); for (var i=1, point=null; i<data.length && point===null; i++) { if (data[i-1].date <= date && data[i].date >= date) { point = data[(Math.abs(data[i-1].date - date) > Math.abs(data[i].date - date)) ? i-1 : i]; } } popupArea.selectAll('*').remove(); if (point !== null) { var popupX = xRange(point.date) + 5; var popupY = yRangeValues(point.high); var popupWidth = 100; var popupHeight = 90; var valueFormatter = d3.format('^$,.2f'); if (popupX + popupWidth > width - margins.right) { popupX = popupX - popupWidth - 10; } var popup = popupArea.append('svg:g') .attr('class', 'popup-text') .attr('transform', 'translate(' + popupX + ',' + popupY +')'); popup.append('svg:rect') .attr('x', 0) .attr('y', 0) .attr('width', popupWidth) .attr('height', popupHeight); popup.append('svg:text') .attr('x', popupWidth / 2) .attr('y', 16) .text(point.date.toLocaleDateString()) .attr('class', 'point-date'); popup.append('svg:text') .attr('x', popupWidth / 2) .attr('y', 29) .text(valueFormatter(point.high).replace(/^\$/, '€').replace(/,/, '.').replace(/\.(\d\d)$/, ',$1')) .attr('class', 'point-value'); if (point.returnPoint !== null) { popup.append('svg:text') .attr('x', popupWidth / 2) .attr('y', 45) .text(point.returnPoint.date.toLocaleDateString()) .attr('class', 'point-date'); popup.append('svg:text') .attr('x', popupWidth / 2) .attr('y', 58) .text(valueFormatter(point.returnPoint.high).replace(/^\$/, '€').replace(/,/, '.').replace(/\.(\d\d)$/, ',$1')) .attr('class', 'point-value'); popup.append('svg:text') .attr('x', popupWidth / 2) .attr('y', 75) .text(point.days + ' days') .attr('class', 'point-value'); //popupArea.append('circle') //.attr('cx', xRange(point.date)) //.attr('cy', yRangeDays(point.days)) //.attr('r', 3) //.attr('class', 'popup-circle'); } popupArea.append('circle') .attr('cx', xRange(point.date)) .attr('cy', yRangeValues(point.high)) .attr('r', 3) .attr('class', 'popup-circle'); } }); |
Adapto mi gráfica al espacio disponible en el navegador
Esta parte del código, tampoco ha variado (excepto que ya no necesito configurar el objeto yRangeDays):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
var redrawSvg = function redrawSvg() { jQuery('#ibex35').css('position', 'absolute'); width = parseInt(jQuery('#ibex35').css('width'))-20; height = parseInt(jQuery('#wrapper').css('height')) - parseInt(jQuery('#graph-scroll').css('height')) - parseInt(jQuery('#graph-options').css('height')); jQuery('#ibex35').css('position', 'relative'); svg.attr('width', width).attr('height', height); mouseOverArea.attr('width', width - margins.left - margins.right) .attr('height', height - margins.top - margins.bottom); svg.select('g.x.axis').attr('transform', 'translate(0,' + (height - margins.bottom) + ')'); svg.select('g.y.axis.left').attr('transform', 'translate(' + (margins.left) + ',0)'); svg.select('g.y.axis.right').attr('transform', 'translate(' + (width - margins.right) + ',0)'); xRange.range([margins.left, width - margins.right]); yRangeValues.range([height - margins.bottom, margins.top]); //yRangeDays.range([height - margins.bottom, margins.top]); }; |
Dibujo el gráfico principal
El dibujo del gráfico principal, tampoco ha variado:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
var redrawData = function redrawData() { var values = scrollSlider.getValue(); fromDate = new Date(minDate.valueOf() + values[0] * millisecondsToDays); toDate = new Date(minDate.valueOf() + values[1] * millisecondsToDays); var filteredData = data.filter(function(d) { return (d.date >= fromDate && d.date <= toDate); }); svg.data([filteredData]); xRange.domain([fromDate, toDate]); yRangeValues.domain([0, maxValue]); svg.select('g.x.axis').transition().call(xAxis); svg.select('g.y.axis.left').transition().call(yAxisValues); svg.select('path.daily-values').transition().attr('d', function(d) { return valueFunc(d); }); }; |
Sólo insistir en un detalle que ya expliqué en la versión anterior, pero que es muy importante: el conjunto de datos a mostrar (que está referenciado por la variable filteredData
es asignado al objeto svg
(que es el contenedor principal) usando el método data
de D3.js. Tal como explicamos, cuando dibujamos la gráfica le pedimos que use esos datos que hemos asignado:
1 |
svg.select('path.daily-values').transition().attr('d', function(d) { return valueFunc(d); }); |
Estos datos que hemos asignado, permanecen en el elemento svg
y ya veremos que también los usamos para dibujar mi gráfica secundaria.
Calculo los datos para la nueva gráfica
El método de cálculo de los datos para mi gráfica secundaria, sigue siendo el mismo:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
var calcReturnDays = function() { for (var i=0; i<data.length; i++) { var returnValue = data[i].high * (1 + returnRate / 100); data[i].days = null; data[i].returnPoint = null; for (var j=i+1; j<data.length && data[i].days===null; j++) { if (data[j].high >= returnValue) { data[i].days = Math.min(j-i+1, daysLimit); data[i].returnPoint = data[j]; } } } }; |
Dibujo la segunda gráfica
Y, por fin, aquí es donde encontramos las diferencias:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
var redrawNumDays = function(recalc) { recalc = (recalc === undefined || true); if (recalc) { calcReturnDays(); } //yRangeDays.domain([0, parseInt(daysLimit)]); //svg.select('g.y.axis.right').transition().call(yAxisDays); var slotWidth = xRange(new Date(fromDate.valueOf() + 1 * millisecondsToDays)) - xRange(fromDate); var width = 0; if (slotWidth >= 5) { width = Math.min(2, parseInt(slotWidth / 2)); } var calcColor = function(d) { var r, g, b; if (d.days === null) { r = 246; g = 246; b = 246; } else { r = 205 + Math.round(((252 - 205) / daysLimit * Math.min(daysLimit, d.days))); g = 235 + Math.round(((63 - 235) / daysLimit * Math.min(daysLimit, d.days))); b = 139 + Math.round(((30 - 139) / daysLimit * Math.min(daysLimit, d.days))); } return 'fill:rgba(' + r + ',' + g + ',' + b + ',' + '0.5' + ');'; }; var rects = svg.select('g.return-days').selectAll('rect').data(function(d) { return d; }); rects.enter().append('rect'); rects.attr('x', function(d) { return xRange(d.date) - width; }) .attr('y', margins.top) .attr('height', height - margins.bottom - margins.top) .attr('width', function() { return 2 * width + 1; }) .attr('style', function(d) { return calcColor(d); }); rects.exit().remove(); //svg.select('path.return-days').transition().attr('d', function(d) { return dayFunc(d); }); }; |
- El recálculo de los datos, sigue igual.
- La configuración del objeto
yRangeDays
y el dibujo del eje vertical derecho los he eliminado.
A continuación, viene el dibujo del nuevo mapa de colores. Básicamente, lo que haré será dibujar un rectangulo que se expanda a todo lo largo de la gráfica y centrado en el punto. El ancho, lo calcularé en función de la separación en píxeles entre entre puntos consecutivos (si hay muchos puntos, será una simple línea; si hay pocos puntos, el rectángulo se extenderá 2 píxeles a la izquierda y derecha del punto a marcar). El color de este rectángulo dependerá del valor days
a codificar.
Primero, unos cálculos preliminares:
- Calculo la variable
width
(la anchura del rectángulo que quiero dibujar será2 * width + 1
). Básicamente, calculo el espacio horizontal (en pixeles) entre dos puntos de la gráfica (la variableslotWidth
). Si esta diferencia es mayor o igual a 5 píxeles,width
valdrá la mitad de esta diferencia (con un tope de 2 píxeles). Si es menor que 5 píxeles, el valor dewidth
será 0 píxel (el rectángulo se convierte en una línea). - Después, defino la función
calcColor
. Esta función, es la encargada de calcular el color asociado a cada valor de mi gráfica secundaria (en función del valor de la propiedadd.days
): - Si el valor es
null
(lo que ocurre cuando no ha conseguido la revalorización que queríamos), el color asignado es RGB(246,246,246). - Si el valor es 0, el color calculado será RGB(205,235,139).
- Si el valor es igual o mayor al límite
daysLimit
, el color calculado será RGB(252,63,30). - Para el resto de valores entre 0 y
daysLimit
, interpolará el valor.
Ahora, viene lo interesante: ya insistí antes que los valores a representar ya estaban asociados al objeto contenedor svg
. Y, dentro de este contenedor, ya había creado previamente un elemento g
de la clase return-days
que voy a usar como contenedor de los rectángulos coloreados. Lo que hago, es solicitar la operación selectAll
sobre este objeto g
:
1 |
var rects = svg.select('g.return-days').selectAll('rect').data(function(d) { return d; }); |
Esta simple operación, tiene muchísimo que estudiar:
- Primero, seleccciono el objeto
g.return-days
(que, como acabo de decir, es el contenedor de los rectángulos). - A esta selección, le pido los objetos
rect
que contiene. - A esta selección de objetos
rect
, le aplico la operacióndata
y le asocio los valores ya asignados al objeto contenedorsvg
. - Por fin, guardo el resultado de esta última operación en la variable
rects
para operar con ella.
Cuando hemos pedido la operación .selectAll('rect').data(function(d) { return d; });
hemos creado una estructura interna de D3.js que «empareja» cada objeto rect
encontrado con uno de nuestros datos. Este «emparejamiento», puede tener tres alternativas:
- Que por cada objeto
rect
haya un dato emparejado. - Que haya más objetos
rect
que datos. - Que haya más datos que objetos
rect
.
(Por ejemplo, cuando la gráfica esté recién creada, habrá 0 objetos rect
y habrá un dato por cada día del histórico de datos, o sea, un «desparejamiento» completo)
Se supone que debe haber tantos objetos como datos. Para conseguir este equilibrio, D3.js nos proporciona una serie de métodos que podemos operar sobre el objeto rects
.
Con el método enter
estamos pidiéndole a D3.js “por cada dato que no esté emparejado, crea un objeto rect
”:
1 |
rects.enter().append('rect'); |
IMPORTANTE: al método enter
podemos pedirle lo que nos dé la gana. En este caso, le hemos pedido append('rect')
para emparejar cada dato «huérfano» con un nuevo rectángulo porque es lo que queremos, pero insisto que enter
no sabe si estamos emparejando rectángulos o círculos o… lo que queramos.
También hemos aplicado la operación exit
:
1 |
rects.exit().remove(); |
Esta operación, lo que hace es borrar los objetos encontrados y que no tengan dato emparejado. O sea: borro los rectángulos que sobren. Vuelvo a insistir en el mismo punto que antes: D3.js no sabe qué tipo de objetos estamos emparejando, sólo sabe que hicimos un emparejamiento entre objetos rect
y datos (.selectAll('rect').data(function(d) { return d; });
) y le pedimos que borre los objetos que no tengan emparejado datos.
La última operación que nos falta por explicar es:
1 2 3 4 5 |
rects.attr('x', function(d) { return xRange(d.date) - width; }) .attr('y', margins.top) .attr('height', height - margins.bottom - margins.top) .attr('width', function() { return 2 * width + 1; }) .attr('style', function(d) { return calcColor(d); }); |
Esta operación se aplica a todos los objetos rect
existentes (y cada uno, como insisto, emparejado a un dato). Para cada objeto rect
, defino sus características:
- Su anchura es
2 * width + 1
. - Su altura, será la de la gráfica (menos el espacio reservado para los bordes superior e inferior).
- Su posición
x
será la posición del punto a la que restamos el valor dewidth
(para que el rectángulo esté centrado en el punto). - Su color, lo calculo mediante la función
calcColor
que definí previamente y que mapeará el valor asociado al punto con un color.
Inicialización de la gráfica
La inicialización no ha variado respecto de la versión anterior:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
redrawSvg(); d3.csv('./data/YAHOO-IBEX.csv') .row(function(d) { return { date: new Date(d.Date), high: +d.High }; }) .get(function(error, rows) { data = rows; fromDate = minDate = d3.min(data, function(d) { return d.date; }); toDate = maxDate = d3.max(data, function(d) { return d.date; }); maxValue = d3.max(data, function(d) { return d.high; }); var numDays = (maxDate - minDate) / millisecondsToDays; scrollSlider.setAttribute('min', 0); scrollSlider.setAttribute('max', numDays); scrollSlider.setValue([0, numDays]); redrawData(); redrawNumDays(); }); jQuery(window).resize(function() { redrawSvg(); redrawData(); redrawNumDays(); }); |
¿Conclusiones?
Esta nueva representación de la gráfica secundaria nos ha dado una oportunidad para profundizar en las capacidades de D3.js para representar gráficamente cualquier dato (sólo limitados por nuestra imaginación y dominio de las librerías).
D3.js está orientada a representar datos mediante elementos HTML. Estos elementos, pueden ser cualquier elemento HTML. Pero, por lo general, usaremos elementos SVG. Para ayudarnos con estas tareas de representar datos mediantes elementos HTML, D3.js tiene métodos especialmente preparados para ello.
En la versión anterior de la gráfica, ya presentamos el método D3.data
que nos permite asignar valores a un objeto para poder posteriormente usar estos valores asociados. Nuestras gráficas, las construímos fácilmente porque usamos el método D3.line
que nos permite construir un objeto path
donde cada punto está asociado a un dato. Por eso, construir estas gráficas era relativamente directo.
Esta vez, hemos construido una representación gráfica usando un objeto rect
por cada dato. Para ello, conforme varían los datos, tenemos que añadir más objetos rect
(usando D3.enter
) o borrar lo que ya no son necesarios (usando D3.exit
).
Estos métodos de D3.js (data
, enter
, exit
) requerirían de entradas específicas para explicarlos detenidamente. Pero, por lo menos, con la excusa de nuestra gráfica, espero haberos dado la oportunidad de conocerlos e introduciros en la operativa básica de D3.js.