En esta nueva versión de la gráfica quiero que, cuando el usuario mueva el ratón sobre la gráfica, se muestre en un popup el valor sobre el que está posicionado el ratón. Para ello, tendré que usar el evento mouseover
de d3.js.
El resultado final
El resultado final es el siguiente (si movéis el ratón sobre la gráfica, debe aparecer un popup con el valor sobre el que estáis):
Podéis abrir este mismo resultado en una ventana independiente aquí.
El código html
El código html no ha experimentado ningún cambio respecto de la versión anterior.
El código css
El código css ha variado un poco … pero permitidme que, esta vez, no explique los cambios hasta el final de esta entrada.
El código javascript
Primero, como siempre, el código javascript completo:
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 |
'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 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(); }); 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:path').attr('class', 'daily-values'); var xRange = d3.time.scale(); var yRange = d3.scale.linear(); var xAxis = d3.svg.axis() .scale(xRange) .tickSize(5) .tickSubdivide(true); var yAxis = d3.svg.axis() .scale(yRange) .tickSize(5) .orient('left') .tickSubdivide(true); var valueFunc = d3.svg.line() .x(function(d) { return xRange(d.date); }) .y(function(d) { return yRange(d.high); }) .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, pointDate=null, pointValue=null; i<data.length && pointDate===null; i++) { if (data[i-1].date <= date && data[i].date >= date) { var point = (Math.abs(data[i-1].date - date) > Math.abs(data[i].date - date)) ? i-1 : i; pointDate = data[point].date; pointValue = data[point].high; } } popupArea.selectAll('*').remove(); if (pointDate !== null) { var popupX = xRange(pointDate) + 5; var popupY = yRange(pointValue); var popupWidth = 100; var popupHeight = 50; 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', 12) .text(pointDate.toLocaleDateString()) .attr('class', 'point-date'); popup.append('svg:text') .attr('x', popupWidth / 2) .attr('y', 30) .text(valueFormatter(pointValue).replace(/^\$/, '€').replace(/,/, '.').replace(/\.(\d\d)$/, ',$1')) .attr('class', 'point-value'); popupArea.append('circle') .attr('cx', xRange(pointDate)) .attr('cy', yRange(pointValue)) .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')); 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').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 ver ahora los puntos más interesantes
Definiciones iniciales y definición del slider
La primera parte de las definiciones iniciales, son iguales a las 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 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 |
(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 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(); }); 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:path').attr('class', 'daily-values'); var xRange = d3.time.scale(); var yRange = d3.scale.linear(); var xAxis = d3.svg.axis() .scale(xRange) .tickSize(5) .tickSubdivide(true); var yAxis = d3.svg.axis() .scale(yRange) .tickSize(5) .orient('left') .tickSubdivide(true); var valueFunc = d3.svg.line() .x(function(d) { return xRange(d.date); }) .y(function(d) { return yRange(d.high); }) .interpolate('linear'); |
A continuación, viene la definición de dos elemento svg nuevos:
1 2 3 4 5 6 |
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'); |
Vemos que hemos añadido dos nuevos elementos:
- Un elemento
g
al que hemos asignado la clasepopup-area
. Este elemento, será el que usemos para mostrar en él la información del popup. - Un elemento
rect
al que hemos asignado la clasemouseover-area
.
La función de este nuevo rect
será cubrir todo el área de la gráfica. Una vez que la cubra, activaremos sobre este elemento el evento mouseover
de modo que así sabremos cuándo el usuario está sobre la gráfica para mostrar el popup.
Quizás la creación de este elemento necesite una explicación. Está claro que necesitábamos programar el evento mouseover
sobre alguno de los elementos svg de nuestra gráfica. Pero:
- Si lo programábamos sobre el elemento
svg
contenedor de todo, éste también incluía cuando se movía el ratón sobre los ejes de coordenadas. Y el efecto no quedaba bien. - Si lo programábamos sobre la elemento
path
de la gráfica, no funcionaba. - Sin embargo, si creamos un elemento
rect
que cubra todo el cuadrante de la gráfica, ése es el espacio sobre el que quiero activar elmouseover
. - Además, configuraré este nuevo elemento
rect
de color transparente, para que sea invisible para el resto de la gráfica.
Otro punto importante es que primero defino el elemento g
que voy a usar para contener del popup y después defino el elemento rect
. El motivo para este orden concreto es más sutil:
- En svg, no existe el concepto de capas (como el elemento
z-index
de css). De modo que si dos elementos svg coinciden en la misma posición, estará en primer plano el elemento que se haya definido el último. - Como he explicado, mi elemento
rect
cubre todo el área de la gráfica y estoy pendiente de su eventomouseover
para mostrar el elementog
que contendrá el popup con la información del punto. Pero si este elementog
lo posiciono por delante de mi elementorect
–> si muevo el ratón sobre el popup, éste ocultará al elementorect
y no se disparará el eventomouseover
. - Así pues, en el orden en que los he creado, el elemento
rect
estará en primer plano respecto del elementog
y éste último no interceptará al eventomouseover
cuando el mueva el ratón por encima. - Pero, si el elemento
rect
está en primer plano ¿no ocultará al elementog
del popup?. No lo hará, porque ya expliqué antes que el he configurado el elementorect
de color transparente. Así que el popup se verá sin problemas.
Captura del movimiento del ratón
Ahora, viene la parte del código realmente nueva:
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 |
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, pointDate=null, pointValue=null; i<data.length && pointDate===null; i++) { if (data[i-1].date <= date && data[i].date >= date) { var point = (Math.abs(data[i-1].date - date) > Math.abs(data[i].date - date)) ? i-1 : i; pointDate = data[point].date; pointValue = data[point].high; } } popupArea.selectAll('*').remove(); if (pointDate !== null) { var popupX = xRange(pointDate) + 5; var popupY = yRange(pointValue); var popupWidth = 100; var popupHeight = 50; 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', 12) .text(pointDate.toLocaleDateString()) .attr('class', 'point-date'); popup.append('svg:text') .attr('x', popupWidth / 2) .attr('y', 30) .text(valueFormatter(pointValue).replace(/^\$/, '€').replace(/,/, '.').replace(/\.(\d\d)$/, ',$1')) .attr('class', 'point-value'); popupArea.append('circle') .attr('cx', xRange(pointDate)) .attr('cy', yRange(pointValue)) .attr('r', 3) .attr('class', 'popup-circle'); } }); |
Vamos a estudiar este código, paso a paso. Primero, programo el evento mouseout
para que cuando el ratón abandone la zona de la gráfica, borre el contenido del elemento g
que contiene el popup:
1 2 3 |
mouseOverArea.on('mouseout', function() { popupArea.selectAll('*').remove(); }); |
Después, programo la captura del movimiento del ratón sobre la gráfica:
1 2 3 4 5 6 7 8 9 10 11 |
mouseOverArea.on('mousemove', function() { var posX = d3.mouse(this)[0]; var date = xRange.invert(posX); for (var i=1, pointDate=null, pointValue=null; i<data.length && pointDate===null; i++) { if (data[i-1].date <= date && data[i].date >= date) { var point = (Math.abs(data[i-1].date - date) > Math.abs(data[i].date - date)) ? i-1 : i; pointDate = data[point].date; pointValue = data[point].high; } } |
Obtengo la posición sobre la que está el ratón de la propiedad d3.mouse(this)[0]
. Para calcular a qué fecha corresponde esta posición, uso mi escala horizontal xRange
. Como vemos, una escala no solo sirve para mapear valores a posiciones, sino también para el proceso inverso (usando el método invert
). A la posición del ratón, le debo restar el margen izquierdo. También le resto 5 (es un valor que he calculado tanteando, porque parece que la posición devuelta por clientX
no coincide con la punta del ratón.
Una vez que tengo la fecha asociada a la posición del ratón, busco en mi lista de datos la fecha más aproximada para la que tengo datos.
Por fin, una vez que ya tengo calculado el punto que quiero mostrar (su fecha en la variable pointDate
y su valor en la variable pointValue
) lo único que falta es borrar el contenido previo del elemento g
que he elegido para contener el popup (y que ya tengo referenciado en la variable popupArea
) y añadirle dentro un pequeño círculo centrado en el punto que hemos calculado y el nuevo popup con estos datos:
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 |
popupArea.selectAll('*').remove(); if (pointDate !== null) { var popupX = xRange(pointDate) + 5; var popupY = yRange(pointValue); var popupWidth = 100; var popupHeight = 50; 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', 12) .text(pointDate.toLocaleDateString()) .attr('class', 'point-date'); popup.append('svg:text') .attr('x', popupWidth / 2) .attr('y', 30) .text(valueFormatter(pointValue).replace(/^\$/, '€').replace(/,/, '.').replace(/\.(\d\d)$/, ',$1')) .attr('class', 'point-value'); popupArea.append('circle') .attr('cx', xRange(pointDate)) .attr('cy', yRange(pointValue)) .attr('r', 3) .attr('class', 'popup-circle'); } }); |
Para mostrar el valor uso el método d3.format
al que le paso una especificación y me devuelve un formateador. Una vez que paso el valor por el formateador, tenemos el problema de que este formateador sólo está preparado para cifras en dólares y formato americano. Por eso, antes de mostrarla, convierto el valor formatado a euros y formato español.
También vemos que a cada elemento le he asignado clases específicas (y que usaremos en nuestro fichero css).
Dibujo e inicialización de la gráfica
La última parte del código (donde dibujo la gráfica e inicializo la gráfica), es exactamente igual a la versión anterior.
La única diferencia, es que también debo configurar el nuevo elemento rect
sobre el que he explicado que he programado la captura del ratón (referenciado por la variable mouseOverArea
) para que cubra toda la anchura de la gráfica (excepto los márgenes)
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 |
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); 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').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); |
El código css
Si estudiáis el nuevo código, comprobaréis que no tengo especificado ningún tipo de información sobre colores. El motivo es que esta información la he incluido dentro del fichero css:
1 2 3 4 5 6 7 |
g.popup-text rect { fill:green; stroke:green; fill-opacity:0.5; stroke-opacity:0.5; rx:7; ry:7; } g.popup-text text.point-date { font-family:sans-serif; text-anchor:middle; font-weight:bold; font-size:10px; } g.popup-text text.point-value { font-family:sans-serif; text-anchor:middle; font-weight:bold; font-size:12px; } circle.popup-circle { stroke:green; fill:green; fill-opacity:0; } rect.mouseover-area { fill-opacity:0; stroke-opacity:0; } |
Efectivamente, mis elementos svg forman parte del DOM del documento y por lo tanto puedo aplicarles reglas css:
- El rectángulo que contendrá el popup (identificado por la clase
popup-text
) lo he configurado de color verde y un poco transparente. - He configurado los textos de la fecha y el valor (identificados por las clases
point-date
ypoint-value
, respectivamente). - El circulito que me resaltará el punto (de clase
popup-circle
) lo he configurado también de color verde. - Y, sobre todo, he configurado el rectángulo sobre el que he capturado el ratón (de la clase
mouseover-area
) para que sea transparente.