Published
Edited
Jul 14, 2020
Insert cell
Insert cell
// Levantamos los planetas del dataset de devstronomy, que tiene un json publicado en github.
planetsJson = await d3.json('https://raw.githubusercontent.com/devstronomy/nasa-data-scraper/master/data/json/planets.json');
Insert cell
// Filtro los planetas en una celda aparte para sacar código del gráfico, que ya es bastante complejo.
planetsData = {
// Dejamos solo los planetas seleccionados.
const planets = planetsJson.filter(p => selectedPlanets.some(sp => sp == p.id.toString()));
// Scaler para que la distancia al sol entre en el gráfico.
const scaleDistanceFromSun = d3.scaleLinear()
.domain([0, d3.max(planets, p => p.distanceFromSun)])
.range([0, width/2 - margin]);
planets.forEach(planet => {
// Dejo la distancia al sol escalada como un nuevo atributo de cada planeta.
planet.distanceFromSunScaled = scaleDistanceFromSun(planet.distanceFromSun);
});
return planets;
}
Insert cell
Insert cell
Insert cell
Insert cell
{
const svg = d3.create("svg").attr('width', width).attr('height', height).attr('class', 'canvas-solar-system');
let mouseX = 0, mouseY = 0, mousePageX = 0, mousePageY = 0;
let isMouseIn = false;
// Definimos el tamaño de cada círculo (planeta y sol) en función del
const circleSize =
d3.max(planetsJson, p => p.distanceFromSun) / d3.max(planetsData, p => p.distanceFromSun);
const container = svg.append('g');
// Ocultamos el tooltip por si quedó visible de una corrida anterior.
tooltipPlanet.style('display', 'none');
// Agregamos el sol en el centro.
container.append('circle')
.attr('r', circleSize)
.attr('cx', width/2)
.attr('cy', height/2)
.attr('class', 'sun');
// Agregamos los planetas y sus órbitas.
container.selectAll('g.planet').data(planetsData).enter()
.append('g').each(function(d, i) {
const thisD3 = d3.select(this);
// Agregamos las orbitas.
thisD3.append('circle').attr('class', 'orbit')
.attr('r', d.distanceFromSunScaled)
.attr('cx', width/2).attr('cy', height/2);
// Agregamos los planetas.
thisD3.append('circle').attr('class', 'planet')
// Si uso el radio escalado a las distancias reales, no se ve absolutamente nada, ni un punto,
// lo cual tiene sentido considerando que el tamaño de cada planeta es ínfimo con respecto al
// radio de las órbitas.
//.attr('r', d.radioScaled)
.attr('r', circleSize)
.attr('cx', d.distanceFromSunScaled).attr('cy', 0);
});
// Trasladamos las coordenadas de los planetas considerando el sol como el centro,
// y también seteamos la posición inicial.
container.selectAll('.planet').attr('transform', d => getTransformStringForPlanet(d));
svg.call(
d3.zoom()
.extent([[0, 0], [width, height]]) // El área zoomeable
.scaleExtent([0.1, 10]) // Minimo y máximo factor de escala posible
.on('zoom', function() {
container.attr('transform', d3.event.transform);
}));
svg.on('mousemove', function () {
isMouseIn = true;
tooltipPlanet.style('display', 'block');
// Capturamos los valores de la posición del mouse en el canvas y en la página, para usarlos después,
// en el while(true) de la animación.
mouseX = d3.event.layerX;
mouseY = d3.event.layerY;
mousePageX = d3.event.pageX;
mousePageY = d3.event.pageY;
}).on('mouseleave', function (){
// Este if es un truco para evitar ignorar el evento cuando no se dispara saliendo efectivamente del svg.
if(d3.event.relatedTarget === null || d3.event.relatedTarget.nodeName !== 'HTML') return;
isMouseIn = false;
tooltipPlanet.style('display', 'none');
});
function selectClosesNodeToMouse() {
let closestNodeToMouse = null;
let minDistance = Number.MAX_SAFE_INTEGER;
let closestCoords = null;
container.selectAll('.planet').nodes().forEach(function (node) {
let coords = getElementCoords(node);
let currentDistance = getDistance(coords.x, coords.y, mouseX, mouseY);
if (currentDistance < minDistance) {
closestCoords = coords;
minDistance = currentDistance;
closestNodeToMouse = node;
}
});
// Despintamos todos los planetas.
container.selectAll('.planet').classed('highlight', false);
// Borramos la línea anterior.
svg.selectAll('.mouse-planet-line').remove();
if(!isMouseIn) return;
// Pintamos solo el que está más cerca.
d3.select(closestNodeToMouse).classed('highlight', true);
// Dibujamos una línea entre el planeta más cercano y el mouse.
svg.append('line').attr('class', 'mouse-planet-line')
.attr('x1', closestCoords.x).attr('y1', closestCoords.y)
.attr('x2', mouseX).attr('y2', mouseY);
let planet = d3.select(closestNodeToMouse).data()[0];
tooltipPlanet
.style('top', mousePageY + 'px')
.style('left', mousePageX + 'px')
.html(`
<b>${planet.name}</b><br/>
${planet.distanceFromSun}M km<br/>
`);;
}
function getDistance(x1, y1, x2, y2) {
return Math.sqrt((x1 - x2)**2 + (y1 - y2)**2);
}
// Códingo para obtener las coordenadas de un HtmlElement(SVG) teniendo en cuenta las transformaciones.
// https://stackoverflow.com/questions/18554224/getting-screen-positions-of-d3-nodes-after-transform/18561829#answer-18561829
function getElementCoords(element) {
let ctm = element.getCTM();
let coords = {
x: element.getAttribute('cx'),
y: element.getAttribute('cy')
};
return {
x: ctm.e + coords.x*ctm.a + coords.y*ctm.c,
y: ctm.f + coords.x*ctm.b + coords.y*ctm.d
};
};
// Función para trasladar las coordenadas de cada planeta considerando el sol como el centro,
// y también para determinar la posición en la órbita, según la velocidad orbital de cada uno.
function getTransformStringForPlanet (planet) {
const delta = (Date.now() - initTime);
let transformString = `translate(${width/2}, ${height/2}) ` +
`rotate(${delta * planet.orbitalVelocity / (1100 - rotationSpeed * 100)})`;
return transformString;
}
//return svg.node();
// Animación para la rotación de los planetas.
//*
while(true) {
yield svg.node();
container.selectAll('.planet').attr('transform', d => getTransformStringForPlanet(d));
selectClosesNodeToMouse();
}
//*/
// Otra forma de animar, pero que deja corriendo un setInterval huérfano cada vez que se ejecuta la celda.
/*setInterval(function(){
container.selectAll('.planet').attr('transform', d => getTransformStringForPlanet(d));
selectClosesNodeToMouse();
}, 0);*/
}
Insert cell
// Lo definimos afuera del gráfico para que solo se ejecute una vez
tooltipPlanet = {
// Primero borramos el tooltip de la vez anterior que se ejecutó esta celta, just in case.
d3.select('body > div.tooltip-planet').remove();
return d3.select("body").append("div")
.attr('class', 'tooltip-planet')
.style('display', 'none')
.text('');
}
Insert cell
Insert cell
{
const svg = d3.create("svg").attr('width', width).attr('height', height/2).attr('class', 'canvas-planets');
const container = svg.append('g');
const spaceBetweenPlanets = 10;
// No filtramos los planetas en este caso, usamos todos.
const planets = planetsJson;
// Scaler para el radio de los planetas, para que entren en el gráfico.
const scaleRadio = d3.scaleLinear()
.domain([0, d3.max(planets, p => p.diameter/2)])
.range([0, (width/2 - margin) / 3.5]);
// Dejo como dato de cada planeta el radio escalado, para no tener que calcularlo después.
planets.forEach(planet => {
planet.radioScaled = scaleRadio(planet.diameter/2);
});
// Ocultamos el tooltipo por si estaba visible de una ejecución anterior.
tooltipDiameter.style('display', 'none');
const sunDiameter = 1391016;
// Escalamos el radio del sol con el mismo de los planetas, para que se pueda apreciar la diferencia de tamaño.
const sunScaledRadio = scaleRadio(sunDiameter/2);
// Agregamos el sol a la izquierda, por fuera del gráfico. Sólo se va a ver un pedacito.
container.append('circle')
.attr('r', sunScaledRadio)
.attr('cx', -sunScaledRadio + margin - spaceBetweenPlanets)
.attr('cy', height/4)
.attr('class', 'celestial-body sun')
.on('mousemove', function () {
// Como no tenemos los datos del sol en el array de planetas, los hardcodeamos en la llamada al onMouseMove.
onMouseMove({
name: 'Sun',
diameter: sunDiameter,
gravity: 274,
lengthOfDay: 24.5*24,
orbitalPeriod: 0
});
})
.on('mouseout', onMouseOut);
// Acumulador con el total de espacio ocupado en el eje x, para poder ir posicionando los planetas
// uno al lado del otro.
let totalDiameterSoFar = margin;
container.selectAll('g.planet').data(planets).enter()
.append('g').each(function(d, i) {
const thisD3 = d3.select(this);
totalDiameterSoFar += d.radioScaled * 2;
// Agregamos el planeta.
thisD3.append('circle').attr('class', 'celestial-body planet')
// Le agregamos una clase con el nombre, que tiene un color asociado a cada planeta.
.classed(d.name, true)
.attr('r', d.radioScaled)
.attr('cx', totalDiameterSoFar - d.radioScaled).attr('cy', height / 4)
.on('mousemove', function () {
onMouseMove(d);
})
.on('mouseout', onMouseOut);
totalDiameterSoFar += spaceBetweenPlanets;
});
svg.call(
d3.zoom()
.extent([[0, 0], [width, height/2]]) // El área zoomeable
.scaleExtent([0.1, 10]) // Minimo y máximo factor de escala posible
.on('zoom', function() {
container.attr('transform', d3.event.transform);
}));
function onMouseOut() {
tooltipDiameter.style('display', 'none');
}
function onMouseMove(planet) {
// Mostramos el tooltip y lo posicionamos a donde está el mouse. Además agrgamos los datos del planeta.
tooltipDiameter.style('display', 'block')
.style('top', d3.event.pageY + 'px')
.style('left', d3.event.pageX + 'px')
.html(`
<b>${planet.name}</b><br/>
Diameter: ${planet.diameter} km<br/>
Gravity: ${planet.gravity} m/s&sup2;<br/>
Day: ${planet.lengthOfDay} hours<br/>
Year: ${planet.orbitalPeriod} days<br/>
`);
};
return svg.node();
}
Insert cell
tooltipDiameter = {
// Primero borramos el tooltip de la vez anterior que se ejecutó esta celta, just in case.
d3.select('body > div.tooltip-diameter').remove();
return d3.select('body').append('div')
.attr('class', 'tooltip-diameter')
.style('display', 'none')
.text('');
}
Insert cell
Insert cell
initTime = new Date().getTime();
Insert cell
height = width;
Insert cell
margin = 50;
Insert cell
html`<style>
.canvas-solar-system {
background-color: black;
}

.canvas-planets {
background-color: black;
}

.sun {
fill: yellow;
}

.orbit {
stroke: #ccc;
fill: transparent;
stroke-width: 0.5px;
}

.planet {
fill: cyan;
}

.planet.highlight {
fill: red;
}

.mouse-planet-line {
stroke: #ccc;
stroke-width: 2px;
}

.tooltip-planet, .tooltip-diameter {
position: absolute;
margin-left: 10px;
margin-top: 10px;
background-color: transparent;
color: white;
font-family: Arial, Helvetica, sans-serif;
}
.tooltip-diameter {
background-color: gray;
border-radius: 5px;
padding: 5px;
border-color: white;
border-style: solid;
border-width: 2px;
}

.celestial-body {
opacity: 0.9;
}
.celestial-body:hover {
stroke: white;
stroke-width: 3;
opacity: 1;
}

.planet.Mercury {
fill: #D4CCC5;
}
.planet.Venus {
fill: #99CC32;
}
.planet.Earth {
fill: #007FFF;
}
.planet.Mars {
fill: #FF7700;
}
.planet.Jupiter {
fill: #D98719;
}
.planet.Saturn {
fill: #B5A642;
}
.planet.Uranus {
fill: #7093DB;
}
.planet.Neptune {
fill: #7093DB;
}
.planet.Pluto {
fill: rgb(97, 86, 80);
}

</style>`
Insert cell
Insert cell
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more