En la entrada anterior añadimos a la gráfica un par de selectores de fechas, para poder realizar un zoom en una zona específica de la gráfica. Sin embargo, el resultado no terminaba de convencerme. Así que en esta nueva versión voy a sustituir los selectores de fechas por un slider que permita cumplir dicha función de una forma más interactiva.
El resultado final
El resultado final es el siguiente
Podéis abrir este mismo resultado en una ventana independiente aquí.
El código html
1 2 3 4 5 6 7 8 9 10 11 |
<div id="wrapper" class="container-fluid"> <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> |
Para conseguir el slider, he usado el slider bootstrap-slider de seirya. En el html, me limito a crear el elemento input
sobre el que inicializaré el slider en mi javascript.
El código css
He usado el CSS para perfilar un poco el aspecto del slider (sobre todo, para que slider ocupe el ancho disponible de la pantalla):
1 2 3 4 5 |
#graph-scroll { width:95%; margin-left:auto; margin-right:auto; } #graph-scroll .form-group { width:100%; } #scroll { width:100%; } #scroll .slider-selection { background:#9F9FF4; } #scroll .slider-handle { border-bottom-color:blue; } |
El código javascript
El código javascript es el siguiente:
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 |
'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 svg, xRange, yRange, xAxis, yAxis, valueFunc; var scrollSlider; var millisecondsToDays = (1000 * 60 * 60 * 24); 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(); }); svg = d3.selectAll('div#ibex35').append('svg'); svg.append('svg:g').attr('class', 'y axis left'); svg.append('svg:path').attr('class', 'daily-values'); svg.append('svg:g').attr('class', 'x axis'); xRange = d3.time.scale(); yRange = d3.scale.linear(); xAxis = d3.svg.axis() .scale(xRange) .tickSize(5) .tickSubdivide(true); yAxis = d3.svg.axis() .scale(yRange) .tickSize(5) .orient('left') .tickSubdivide(true); valueFunc = d3.svg.line() .x(function(d) { return xRange(d.date); }) .y(function(d) { return yRange(d.high); }) .interpolate('linear'); 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')); jQuery('#ibex35').css('position', 'relative'); svg.attr('width', width).attr('height', height); svg.select('g.x.axis').attr('transform', 'translate(0,' + (height - margins.bottom) + ')'); svg.select('g.y.axis').attr('transform', 'translate(' + (margins.left) + ',0)'); xRange.range([margins.left, width - margins.right]); yRange.range([height - margins.bottom, margins.top]); }; var redrawData = function redrawData() { var values = scrollSlider.getValue(); var newFromDate = new Date(0); var newToDate = new Date(0); newFromDate.setUTCMilliseconds(minDate.valueOf() + values[0] * millisecondsToDays); newToDate.setUTCMilliseconds(minDate.valueOf() + values[1] * millisecondsToDays); fromDate = newFromDate; toDate = newToDate; var filteredData = data.filter(function(d) { return (d.date >= fromDate && d.date <= toDate); }); xRange.domain([fromDate, toDate]); yRange.domain([0, maxValue]); svg.select('g.x.axis').transition().call(xAxis); svg.select('g.y.axis').transition().call(yAxis); svg.select('path.daily-values').transition().attr('d', valueFunc(filteredData)); }; 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; minDate = d3.min(data, function(d) { return d.date; }); maxDate = d3.max(data, function(d) { return d.date; }); fromDate = minDate; toDate = maxDate; 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(); }); jQuery(window).resize(function() { redrawSvg(); redrawData(); }); }); })(window.d3); |
Vamos a estudiarlo poco a poco, centrándonos en las partes nuevas
Definiciones iniciales
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
'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 svg, xRange, yRange, xAxis, yAxis, valueFunc; var scrollSlider; var millisecondsToDays = (1000 * 60 * 60 * 24); |
A las variables anteriores, añado las nuevas variables necesarias para coordinar el slider con la gráfica:
minDate
,maxDate
ymaxValue
guardarán los valores máximos y mínimos de los datos que recupere.scrollSlider
guardará la referencia al slider, para manipularlo.millisecondsToDays
es una constante que usaremos más adelante.
La inicialización del slider
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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(); }); |
En esta inicialización, me limito a:
- Configurar el
formatter
que formatea el texto que aparece en la tooltip asociada al slider. - Configuro el evento
slideStop
del slider para redibujar la gráfica.
Para formatear las etiquetas, el slider me pasa como parámetro value
o un valor o un array de dos valores con los valores vigentes del slider. Como he configurado mi slider para que los valores vayan desde 0 al número máximo de días (esto, lo hago más adelante), convierto este número de días a fechas concretas a partir de la fecha mínima de mis datos minDate
.
El slider tiene varios eventos. Este evento elegido slideStop
se dispara cuando el usuario deja de arrastrar el slider o cuando el usuario hace click en el slider. Me hubiera gustado usar el evento change
para conseguir un efecto más interactivo, pero el rendimiento no me gustaba.
Defino los elementos básicos de la 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 |
svg = d3.selectAll('div#ibex35').append('svg'); svg.append('svg:g').attr('class', 'y axis left'); svg.append('svg:path').attr('class', 'daily-values'); svg.append('svg:g').attr('class', 'x axis'); xRange = d3.time.scale(); yRange = d3.scale.linear(); xAxis = d3.svg.axis() .scale(xRange) .tickSize(5) .tickSubdivide(true); yAxis = d3.svg.axis() .scale(yRange) .tickSize(5) .orient('left') .tickSubdivide(true); valueFunc = d3.svg.line() .x(function(d) { return xRange(d.date); }) .y(function(d) { return yRange(d.high); }) .interpolate('linear'); |
La primera parte de este código, no ha experimentado cambios respecto de la versión anterior. Pero ahora también aprovecho para definir los elementos básicos d3 (xRange
, yRange
, xAxis
, yAxis
, valueFunc
). Estos elementos, eran definidos dentro de la función redrawSvg
y por lo tanto redefinidos cada vez que la gráfica experimentaba un cambio de tamaño. Me he dado cuenta que no es necesario esta redefinición y por eso los he cambiado de lugar en el código.
Defino los función responsable de terminar la definición de los elementos SVG de la gráfica
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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')); jQuery('#ibex35').css('position', 'relative'); svg.attr('width', width).attr('height', height); svg.select('g.x.axis').attr('transform', 'translate(0,' + (height - margins.bottom) + ')'); svg.select('g.y.axis').attr('transform', 'translate(' + (margins.left) + ',0)'); xRange.range([margins.left, width - margins.right]); yRange.range([height - margins.bottom, margins.top]); }; |
Esta parte del código, tampoco ha experimentado cambios respecto de la versión anterior. Excepto que, tal como he explicado previamente, la definiciones de los elementos d3.js ya no las hago aquí. Ahora, me limito a modificar los elementos afectados por los redimensionamientos de la ventana.
Defino la función responsable de dibujar la gráfica
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
var redrawData = function redrawData() { var values = scrollSlider.getValue(); var newFromDate = new Date(0); var newToDate = new Date(0); newFromDate.setUTCMilliseconds(minDate.valueOf() + values[0] * millisecondsToDays); newToDate.setUTCMilliseconds(minDate.valueOf() + values[1] * millisecondsToDays); fromDate = newFromDate; toDate = newToDate; var filteredData = data.filter(function(d) { return (d.date >= fromDate && d.date <= toDate); }); xRange.domain([fromDate, toDate]); yRange.domain([0, maxValue]); svg.select('g.x.axis').transition().call(xAxis); svg.select('g.y.axis').transition().call(yAxis); svg.select('path.daily-values').transition().attr('d', valueFunc(filteredData)); }; |
Esta función, sí que ha cambiado:
- Ahora, busca las fechas límites para mostrar
fromDate
ytoDate
en el nuevo slider. - Para calcular
fromDate
ytoDate
, sé que los valores que devuelve mi sliderscrollSlider
van de 0 días al máximo de días que abarcan mis datos (esto, lo configuro un poco más adelante). - Estos nuevos límites me dan el rango para
xRange
. - El rango para
yRange
es siempre el mismo. Si calculara el valor máximo en el rango mostrado, la escala vertical de la gráfica variaría con el tiempo. - Con los datos filtrados y los rangos configurados, lo redibujo todo.
Carga inicial de la aplicación
Por último, realizo la carga inicial de la aplicación.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
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; minDate = d3.min(data, function(d) { return d.date; }); maxDate = d3.max(data, function(d) { return d.date; }); fromDate = minDate; toDate = maxDate; 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(); }); jQuery(window).resize(function() { redrawSvg(); redrawData(); }); |
Este código, es similar al de la versión anterior. Su diferencia estriba que esta vez tengo que inicializar el slider:
- Calculo los valores máximos de los datos recuperados
minDate
,maxDate
ymaxValue
. - Calculo el número de días
numDays
que abarcan estos datos. - Configuro el slider
scrollSlider
para que abarque desde0
hastanumDays
.
¿Conclusiones?
Como D3.js te proporciona una serie de primitivas básicas, esto te da una flexibilidad tremenda a la hora de planificar tu gráfica. En esta versión, creo que la interactividad ha mejorado. Todavía no es la óptima, pero prefiero investigar otros aspectos antes de optimizar este punto (todo llegará…).