En esta nueva versión de mi gráfica realizada con D3.js, voy a añadirle un nuevo gráfico, superpuesto al anterior.
El resultado final
El resultado final es el siguiente:
Podéis abrir este mismo resultado en una ventana independiente aquí.
El nuevo gráfico superpuesto
Hasta ahora, en todas las versiones que he ido realizando, he usado como base una gráfica lineal con la evolución temporal del índice IBEX35:
A mí, personalmente, me resulta muy interesante ver cómo evoluciona esta gráfica: subidas, bajadas, nuevas subidas, nuevas bajadas, a veces parece que va consolidando valores… para caer de nuevo… y volver a subir.
Hay gente que decide ganarse la vida apostando a la evolución de estas gráficas. E intenta encontrar pautas en la evolución pasada de los valores, para predecir los valores futuros (tanto a corto como a medio plazo). A mí, personalmente, no me atrae ese tipo de estrategias. Pero había una cuestión que me rondaba la cabeza:
- Supongamos que confío en la evolución positiva del IBEX35. Detrás del IBEX35 no deja de haber empresas. Supongamos que son empresas serias. Y puedo suponer que van a hacer bien su labor (que ya es mucho suponer). Y, por lo tanto, van a aportar valor a sus accionistas (que es una manera muy elegante de decir «que va a subir la valoración»).
- Además, no estoy buscando subidas aceleradas. Ninguna empresa madura puede aumentar su valoración real de forma acelerada. Del mismo modo, pienso que la valoración bursátil razonable debe subir gradualmente, en concordancia con los valoración real de las empresas.
- Y este aumento de valoración, no puede ser conseguido a corto plazo, sino a medio o largo plazo. Está claro que el mercado sesga la valoración de estas empresas (a veces, demanda acciones y dispara el valor; otras veces, entra el pánico y el valor cae). Incluso factores externos a la empresa en sí, afectan a su valor (por ejemplo, la «marca España» puede ser cuestionada en ciertos momentos… pero hay algunas empresas que tienen tanto o más negocio fuera de España que dentro de ella). Pero entre momentos de euforia y de pánico debe ir hacia el punto de estabilidad (que debería coincidir con la valoración real de las empresas).
En este contexto, imaginemos que invierto en el IBEX35 y dejo esa inversión madurar, por ejemplo, un año ¿Es razonable pensar que la revalorización de esa inversión puede ser, al menos, similar a si hubiera invertido ese capital en un producto de menor riesgo (letras del tesoro, depósitos financieros,…)?
Para investigar esta última pregunta, quería superponer a mi gráfico original un segundo gráfico:
- Defino un porcentaje de revalorización.
- Para cada punto de mi gráfica, busco el momento futuro en el que se consigue esa revalorización (si es que se consigue alguna vez).
- El número de días transcurridos, para cada punto, me da esta segunda gráfica.
- Como el rango de días necesario puede ser muy amplio, he definido en el interfase un segundo slider que permite indicar un número máximo de días (que me sirve como tope superior para mi nueva gráfica.
Jugando con la gráfica, la casuística que nos podemos encontrar es grande:
- Si decidimos un porcentaje de revaloración del 4% y un plazo de 365 días (hace un par de años, no era extraño encontrar depósitos financieros a un año que te daban este rendimiento, sin ningún tipo de riesgo). Con estos valores, la mayor parte de las veces sí que se cumplen las expectativas. Pero también hay períodos concretos que no se cumplen.
- Si esperamos una revalorización que compense al riesgo (supongamos un 10% de revalorización), el número de períodos que cumplen las expectativas todavía sigue siendo grande.
- Pero si hubieras invertido entre los años 2007 y 2008 … desgraciadamente todavía no habrás recuperado tu inversión.
Bueno, dejémonos de divagaciones filosóficas y vamos al grano… digo… al código.
El código html
El código html necesario es similar al de la versión anterior, excepto que incluyo dos nuevos sliders con id
returnRage
y daysLimit
para poder introducir la revalorización deseada y el tiempo máximo en que espero conseguirla.
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 |
<div id="wrapper" class="container-fluid"> <div class="row" id="graph-options"> <div class="col-xs-12"> <form class="form-inline"> <div class="form-group"> <label for="returnRate">Expected Return Rate :</label> <input id="returnRate" data-slider-id='returnRate' type="text" data-slider-tooltip="hide" data-slider-min="1" data-slider-max="300" data-slider-step="1" data-slider-value="100"/> <label id="returnRateValue">100%</label> </div> <div class="form-group"> <label for="daysLimit">Time Limit :</label> <input id="daysLimit" data-slider-id='daysLimit' type="text" data-slider-tooltip="hide" data-slider-min="1" data-slider-max="3650" data-slider-step="1" data-slider-value="365"/> <label id="daysLimitValue">365 days</label> </div> </form> </div> </div> <div id="ibex35"></div> <div class="row" id="graph-scroll"> <form class="form-inline"> <div class="form-group"> <input type="text" data-slider-min="0" data-slider-max="1000" data-slider-step="1" data-slider-value="[0,1000]" data-slider-id="scroll" id="scroll" data-slider-handle="triangle"/> </div> </p> </div> </div> |
El código javascript
El código javascript es similar a la versión anterior, excepto que ampliado para incluir el nuevo gráfico superpuesto. Bueno, además he aprovechado esta nueva versión para introducir un método fundamental de D3.js: el método data
.
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 |
'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: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); 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 ir leyéndolo, poco a poco.
Definición variables globales
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'); |
Las variables globales son las mismas que la versión anterior más nuevas variables para los dos nuevos parámetros (returnRate
, daysLimit
, returnRateLabel
y daysLimitLabel
).
Definición de los sliders
Al slider scrollSlider
que ya existía en la versión previa, añadimos los dos nuevos sliders returnRate
y daysLimit
.
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 necesarios, son los mismos más:
- Un nuevo
scale
vertical para nuestro nuevo gráfico. Lo he llamadoyRangeDays
. Para que el código sea más claro, elscale
vertical que ya existía lo he renombradoyRangeValues
. - Un nuevo
axis
verticalyAxisDays
que dibujaré en el lado derecho. Elaxis
existente lo he renombradoyAxisValues
. - Un nuevo
line
dayFunc
que usaré para dibujar mi segunda gráfica.
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 |
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: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'); |
Definición del popup
La mecánica para la definición del popup que aparece conforme se mueve el ratón sobre la gráfica, sigue igual. Excepto que he ampliado su contenido. A los dos datos que ya mostraba (point.date
y point.high
he añadido la información asociada a una nueva propiedad point.returnPoint
(creo que es fácil de entender: más adelante, calculo para cada punto de mi gráfica el punto cuando se consigue la revalorización deseada y lo guardo en la nueva propiedad point.returnPoint
; además, guardo los días de diferencia en la nueva propiedad point.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 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 |
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
Como en versiones anteriores, la función redrawsSvg
se encarga de calcular las dimensiones disponibles y de adaptar los distintos elementos a estas dimensiones:
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]); }; |
La única diferencia es que debo adaptar también el nuevo eje vertical (de clase y.axis.right
) y la nueva escala para los días yRangeDays
.
Dibujo el gráfico principal
La función redrawData
sigue encargándose de dibujar el gráfico principal.
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); }); }; |
Pero ahora hay dos diferencias fundamentales:
- Una vez que ha calculado los datos filtrados
filteredData
los pasa al métodosvg.data
. - La gráfica, se genera de forma ligeramente distinta a la versión anterior.
Comparemos la versión anterior con la nueva:
1 2 3 |
// versión anterior var filteredData = data.filter(function(d) { return (d.date >= fromDate && d.date <= toDate); }); svg.select('path.daily-values').transition().attr('d', valueFunc(filteredData)); |
1 2 3 4 |
// versión nueva var filteredData = data.filter(function(d) { return (d.date >= fromDate && d.date <= toDate); }); svg.data([filteredData]); svg.select('path.daily-values').transition().attr('d', function(d) { return valueFunc(d); }); |
La versión anterior, era relativamente fácil de comprender. Pero la nueva versión requiere una explicación más detallada.
El método d3.data
d3.js es una librería orientada a representar gráficamente series de valores. En este contexto, es natural esperar que exista una correlación directa entre los valores representados y los elementos del documento. Por ejemplo:
- Si la serie tiene 10 valores, hacen falta diez columnas.
- La altura de cada columna debe ser proporcional al valor asociado a esa columna.
(Por lo general, estos elementos asociados a nuestras series de valores serán elementos svg
pero no hay inconveniente en que usemos otros elementos estándares como div
, p
, ul
, etc).
Para facilitar este tipo de correlaciones, d3.js dispone del método data
:
- Permite asociar un array de valores a un elemento del documento (que actúa como «padre» o «contenedor»).
- Una vez asociados, dispone de funciones adicionales que permiten coordinar los valores con los elementos que representarán a estos valores.
(Esto está explicado muy someramente. Haría falta una entrada completa para explicarlo detenidamente).
En nuestro caso:
- Tenemos nuestros valores filtrados en la variable
filteredData
. - Asocio dichos valores al elemento contenedor
svg
. - Cuando genero la gráfica, sé que a la función callback que le paso
function(d) { return valueFunc(d); }
D3.js le pasa al argumentod
los valores asociados previamente. Hasta esta versión de mi gráfica, ignoraba dicho parámetro. Pero en esta versión, uso este parámetro para pasarle los valores a mi funciónvalueFunc
.
Un último detalle importante: el método data
espera que se le pase como parámetro un array de datos, que posteriormente podremos mapear en una lista de elementos del DOM (por cada elemento del array, asociará un elemento del documento). Pero, en este caso, tenemos un solo elemento (el elemento path.daily-values
) que espera como dato un array (que usaremos rellenar su atributo d
para generar la gráfica). Por eso, al método data
nosotros le pasamos un array con un solo elemento (que es nuestro array de valores).
Siento que la explicación no sea más extensa. Como he dicho antes, el método data
es uno de los más potentes de D3.js y explicarlo detenidamente necesitaría de una entrada propia.
Calculo los datos para la nueva gráfica
La función calcReturnDays
es la encargada de buscar para cada punto, el punto futuro cuando se consigue la revalorización esperada ¿Os acordáis que cuando se generaba el popup, usaba dos nuevas propiedades de cada punto, days
y returnPoint
? Pues esta función es la responsable de calcularlos:
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]; } } } }; |
Podría haber optimizado mucho más esta función. Pero como no aprecio retrasos apreciables en la representación de la gráfica, he preferido dejarlo así porque creo que es más lineal para comprenderlo.
Dibujo la segunda gráfica
La función redrawNumDays
es la responsable de dibujar la segunda gráfica:
1 2 3 4 5 6 7 8 9 10 11 12 |
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); svg.select('path.return-days').transition().attr('d', function(d) { return dayFunc(d); }); }; |
Su código es muy similar a redrawData
con las siguientes diferencias:
- Sólo invoca a
calcReturnDays
para recalcular los datos asociados si se le pasa su argumentorecalc
atrue
(por ejemplo, cuando se varíen los sliders se recalcularán los datos, pero no se recalcularán si varían las fechas límites). - Usa la misma escala horizontal
xRange
que la gráfica principal pero usa su propia escala verticalyRangeDays
(que es por lo tanto la única que tiene que configurar).
Y, por último, la diferencia fundamental: acordaros que en la función redrawData
ya asociamos los datos al elemento contenedor svg
:
1 |
svg.data([filteredData]); |
Esta asociación de datos que hemos realizado es permantente. Esto quiere decir que los datos a representar ya están asociados al elemento svg
. Por lo tanto, para dibujar nuestra segunda gráfica, sólo tenemos que usarlos.
Es la misma operativa que cuando dibujamos la gráfica principal:
1 |
svg.select('path.daily-values').transition().attr('d', function(d) { return valueFunc(d); }); |
Excepto que para dibujar la segunda gráfica usamos la función dayFunc
.
1 |
svg.select('path.return-days').transition().attr('d', function(d) { return dayFunc(d); }); |
Inicialización de la gráfica
El código para inicializar la gráfica es similar a la versión anterior, excepto que tiene que llamar a redrawNumDays
para dibujar 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 |
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 versión, he aprovechado para incluir una segunda gráfica. Esto quiere decir que tenemos escalas y ejes adicionales para esta nueva gráfica. Las dos gráficas usan el mismo conjunto de datos, excepto que cada una usa propiedades distintas (la gráfica principal usa date
y high
mientras que la gráfica secundaria usa date
y days
). Por eso, he usado el método data
para asociar los datos al contenedor svg
para que así los dos elementos path
que representan cada gráfica busquen los datos que necesitan de este contenedor padre.
Como he dicho antes, el métod data
es muy potente. En esta práctica, me he limitado a usarlo ligeramente. A ver si en próximas versiones se me ocurren nuevas excusas para extender su uso con otros ejemplos.