Using Mike Bostocks example of Path Transitions I have created a line graph to show data with an accompanying plot of icons underneath to call attention to elements in the graph. Here is a JSFiddle showing the result and the relevant code is:
[note: for demonstration I'm appending an image with every data point here, this may not be the case in production]
var data = [
{ "id": 0, "elevation": 90 },
{ "id": 1, "elevation": 73 },
{ "id": 2, "elevation": 70 },
{ "id": 3, "elevation": 59 },
{ "id": 4, "elevation": 63 },
{ "id": 5, "elevation": 65 },
{ "id": 6, "elevation": 61 },
{ "id": 7, "elevation": 59 },
{ "id": 8, "elevation": 60 },
{ "id": 9, "elevation": 62 },
{ "id": 10, "elevation": 64 }
];
var key = function (d) {
return d.id;
};
var xScale = d3.scale.linear()
.domain([0, data.length - 1])
.range([0, settings.containerWidth - 16]);
var yScale = d3.scale.linear()
.domain([0, d3.max(data, function(d) { return d.elevation; })])
.range([settings.containerHeight, 0]);
var svg = d3.select("#animation-container")
.append("svg")
.attr("width", settings.containerWidth)
.attr("height", settings.containerHeight);
var line = d3.svg.line()
.x(function(d, i) { return xScale(i); })
.y(function(d) { return yScale(d.elevation); })
.interpolate("basis");
svg.append("g")
.append("path")
.datum(data, key)
.attr("class", "line")
.attr("d", line);
svg.append("g")
.selectAll(".data-points")
.data(data, key)
.enter()
.append('image')
.attr("x", function (d, i) { return xScale(i); })
.attr("y", settings.iconLine)
.attr("xlink:href", "https://github.com/favicon.ico")
.attr("width", settings.iconWidth)
.attr("height", settings.iconHeight);
My thinking is that the solutions is be related to getting the horizontal scaling correct but I have tried many variations of linear and ordinal scales and transitions vs updating base data to make this work without success.
My question is:
How do I allow both the graph and icons to run off screen?
How do I keep the icons touching each other rather than gaps between them
Where my code differs from Mikes excellent example is that I don't want to
remove the offscreen data as my user will scroll back and forth.
Thanks.
EDIT:
As per #LarsKotthoff suggestion below I amended the range() of the xScale to the following:
var xScale = d3.scale.linear()
.domain([0, data.length - 1])
.range([0, (data.length - 1) * settings.iconWidth]);
This has a double effect:
Don't set the upper bound of the range to the screen as it will scale to the screen. Set it to the length of data. The result is if you have more data than svg width it will run off the edge of the svg boundary naturally.
Have all your icons joined together by creating a range that is a multiple of their sizes i.e. multiplying the range by the icon size.
Here's is the updated JSFiddle
To make a plot extend beyond the border of the SVG, all you need to do is adjust the range of the scales you're using. For example, your x scale is set up as
var xScale = d3.scale.linear()
.domain([0, data.length - 1])
.range([0, settings.containerWidth - 16]);
The range determines where things are drawn on the SVG and is currently restricted to the width of the SVG. To make it extend beyond that, change it to e.g.
var xScale = d3.scale.linear()
.domain([0, data.length - 1])
.range([0, 2 * settings.containerWidth - 16]);
which would make the x scale cover twice the width of the actual SVG -- that is, half of the graph would be visible, while the other half would be "off screen".
Complete demo here.
Note d3.scale.linear is from v3. It changed to d3.scaleLinear in v4:
'scale' and 'svg' does not exist in "node_modules/#types/d3/index"
Related
I would like to create a graphic in D3 that consists of nodes connected to each other with curved lines. The lines should be curved differently depending on how far apart the start and end point of the line are.
For example (A) is a longer connection and therefore is less curved than (C).
Which D3 function is best used for this calculation and how is it output as SVG path
A code example (for example on observablehq.com) would help me a lot.
Here is a code example in obserbavlehq.com
https://observablehq.com/#garciaguillermoa/circles-and-links
I will try to explain it, let me know if there is something I am not clear enough:
Lets start with our circles, we use d3.pie() to position this circles, passing the data defined above, it will return us some arcs, but as we want circles instead of arcs, we use arc.centroid to get the coordinates of our circles
Value is required for the spacing in the pie layout that we use to calculate the position, if you want more circles, you will need to reduce the value, here is the related code:
pie = d3
.pie()
.sort(null)
.value((d) => {
return d.value;
});
arc = d3.arc().outerRadius(300).innerRadius(50);
data = [
{ id: 0, value: 10 },
{ id: 1, value: 10 },
{ id: 2, value: 10 },
{ id: 3, value: 10 },
{ id: 4, value: 10 },
{ id: 5, value: 10 },
{ id: 6, value: 10 },
{ id: 7, value: 10 },
{ id: 8, value: 10 },
{ id: 9, value: 10 },
];
const circles = [];
for(let item of pieData) {
const [x, y] = arc.centroid(item);
circles.push({x, y});
}
Now we can render the circles:
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height);
const mainGroup = svg
.append("g")
.attr("id", "main")
.attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");
// Insert lines and circles groups, lines first so they are behind circles
const linesGroup = mainGroup.append("g").attr("id", "lines");
const circlesGroup = mainGroup.append("g").attr("id", "circles");
circlesGroup
.selectAll("circle")
.data(circles, (_, index) => index)
.join((enter) => {
enter
.append("circle")
.attr("id", (_, index) => {
return `circle-${index}`;
})
.attr("r", 20)
.attr("cx", (d) => {
return d.x;
})
.attr("cy", (d) => {
return d.y;
})
.style("stroke-width", "2px")
.style("stroke", "#000")
.style("fill", "#963cff");
});
Now we need to declare the links, we could do this with an array specifying the id of the source and destination (from and to). we use this to search each circle, get its coordinates (the source and destination of our links) and then create the links, in order to create them, we can use a path and the d3 method quadraticCurveTo, this function requires four parameters, the first two are "the control point" which defines our curve, we use 0, 0 as it is the center of our viz (it is the center because we used a translate in the parent group).
lines = [
{
from: 1,
to: 3,
},
{
from: 8,
to: 4,
},
];
for (let line of lines) {
const fromCircle = circles[line.from];
const toCircle = circles[line.to];
const fromP = { x: fromCircle.x, y: fromCircle.y };
const toP = { x: toCircle.x, y: toCircle.y };
const path = d3.path();
path.moveTo(fromP.x, fromP.y);
path.quadraticCurveTo(0, 0, toP.x, toP.y);
linesGroup
.append("path")
.style("fill", "none")
.style("stroke-width", "2px")
.style("stroke-dasharray", "10 10")
.style("stroke", "#000")
.attr("d", path);
}
I am trying to draw area graph but it is filling below x axis. In below code I have used y0(yScale(0)) as I have seen in many examples and also I tried to give y0(height) it is not giving me correct output. I want area to be filled only above x axis if y axis values are +ve and if y axis values are -ve then area is going above max tick of y axis.
const D3Node = require('d3-node');
getGraphString: (data,yAxisTickFormat) => {
const d3n = new D3Node() // initializes D3 with container element
const d3 = d3n.d3;
let margin = { top: 20, right: 30, bottom: 20, left: 40 },
width = 275,
height = 200;
let svg = d3n.createSVG(width, height+margin.top+margin.bottom);
let xScale = d3.scaleTime().range([margin.left, width - margin.right])
.domain(d3.extent(data, function (d) { return d.date })),
yScale = d3.scaleLinear().range([height - margin.top, margin.bottom])
.domain(d3.extent(data, function (d) { return d.value }));
svg.append('g').attr("class", "xAxis")
.attr("transform", "translate(0," + (height - margin.bottom) + ")") //The transforms are SVG transforms
.call(d3.axisBottom(xScale).ticks(d3.timeYear).tickFormat(d3.timeFormat('%Y')))
.selectAll("text")
.style("text-anchor","end")
.attr("dx", "-.9em")
.attr("dy", ".50em")
.attr("transform","rotate(-45)")
svg.append("g") //We create an SVG Group Element to hold all the elements that the axis function produces.
.attr("transform", "translate(" + (margin.left) + ",0)")
.attr("class","yAxis")
.call(d3.axisLeft(yScale).ticks(4).tickFormat(d3.format(yAxisTickFormat)))
.selectAll("text")
.style("text-anchor","end")
.attr("dy", "0.32em")
.attr("dx", "-0.4em")
let lineFunc = d3.line().x(function (obj) { return xScale(obj.date) })
.y(function (obj) { return yScale(obj.value) })
svg.append("path")
.attr("d", lineFunc(data))
.attr("stroke", '#002046')
.attr("stroke-width", 3)
.attr("fill", "none");
let area = d3.area()
.curve(d3.curveLinear)
.x(function (d) { return xScale(d.date); })
.y0(yScale(0))
.y1(function (d) { return yScale(d.value); });
svg.append("path")
.style("fill", "#002046")
.attr("d", area(data));
return d3n.svgString();
}
let data =[ { date: 2019-01-01T00:00:00.000Z, value: 0.6330419130189774 },
{ date: 2018-01-01T00:00:00.000Z, value: 0.6266752649582236 },
{ date: 2017-01-01T00:00:00.000Z, value: 0.6403446517126394 },
{ date: 2016-01-01T00:00:00.000Z, value: 0.6432956408788177 } ];
getGraphString(data,'.0%');
Problem
If your y axis (scale domain) starts at 0, then yScale(0) is an appropriate baseline for the area. However, your scale's domain extent does not start at 0, it is dependent on the dataset:
yScale = d3.scaleLinear().range([height - margin.top, margin.bottom])
.domain(d3.extent(data, function (d) { return d.value }));
The lowest value in your dataset is 0.6266... not zero. In using yScale(0) D3 interpolates where y(0) would be, which in your case would require extending the axis quite a bit down the page and off the SVG.
Solution
We can manually set the baseline with something like : area.y0(yScale(0.6266...)). This places the baseline at the base of your y axis. But you don't need to set it manually as you can can set it with:
area.y0(yScale.range()[0]);
yScale.range() returns an array containing the scaled extent of the yScale (and therefore the y axis), we want to have the area's base be the same as the axis.
yScale.range()[0] is the equivilant of yScale(yScale.domain()[0]); - if 0 is the minimum value of the domain (0 == yScale.domain()[0]), it's a short jump to the often used area.y0(yScale(0))
Alternative
Alternatively, if you want the axis to include zero, you could keep yScale(0) as the baseline and set 0 to be the minimum value of the scale's domain:
.domain([0,d3.max(function(d) { return d.value; })])
Either way, the value provided as the minimum value for the scale's range and area.y0 should be the same if you want the bottom of the area to be aligned to the bottom of the axis. (This value should generally also be equal to the y translate value for the x axis).
I am getting the following console error:
Error: <circle> attribute cy: Expected length, "NaN".
This is resulting in my circle elements being assigned a value of 0 and being rendered at the top of the screen.
My circles are being passed a Date value as the cy rather than a number, and I have tried using both scaleTime() and scaleLinear() but not had the desired effect.
<circle cx="41.9047619047619" cy="NaN" r="6" class="dot" data-xvalue="1995" data-yvalue="Mon Jan 01 1900 00:36:50 GMT+0000 (Greenwich Mean Time)"></circle>
Code
let width = 500;
let height = 500;
let margin = {left: 20, right: 20, top: 20, bottom: 20};
let timeFormat = d3.timeParse("%M:%S");
// select container and render svg canvas, set height and width.
const chart = d3.select('#container')
.append('svg')
.attr('height', height)
.attr('width', width);
// Fetch data
d3.json('https://raw.githubusercontent.com/freeCodeCamp/ProjectReferenceData/master/cyclist-data.json').then(data => {
// Find highest and lowest years in dataset
const maxYear = d3.max(data, (d) => d.Year);
const minYear = d3.min(data, (d) => d.Year);
// Parse time data
data.forEach(d => {
let timeParser = d3.timeParse('%M:%S')
d.Time = timeParser(d.Time);
});
// Define Scales
let xScale = d3.scaleLinear() // try scaleTime afterwards to check
.domain([minYear, maxYear])
.range([margin.left, width - margin.right]);
let yScale = d3.scaleTime()
.domain([d3.extent(data, (d) => d.Time)])
.range([0, height - margin.top])
// Render circles for each data point
chart.selectAll('circle')
.data(data)
.enter()
.append('circle')
.attr('cx', (d) => xScale(d.Year))
.attr('cy', (d) => yScale(d.Time))
.attr('r', 6)
.attr('class', 'dot')
.attr('data-xvalue', (d) => d.Year)
.attr('data-yvalue', (d) => d.Time)
})
I have tried the following alternatives when I am parsing the time data but still getting the same error:
// Parse time data
data.forEach(d => {
let timeParser = d3.timeParse('%M:%S')
d["Time"] = timeParser(d.Time);
});
// Parse time data
data.forEach(d => {
let timeParser = d3.timeParse('%M:%S')
d.Time = new Date(timeParser(d.Time));
});
JSON data for reference:
{
"Time": "36:50",
"Place": 1,
"Seconds": 2210,
"Name": "Marco Pantani",
"Year": 1995,
"Nationality": "ITA",
"Doping": "Alleged drug use during 1995 due to high hematocrit levels",
"URL": "https://en.wikipedia.org/wiki/Marco_Pantani#Alleged_drug_use"
},
{
"Time": "36:55",
"Place": 2,
"Seconds": 2215,
"Name": "Marco Pantani",
"Year": 1997,
"Nationality": "ITA",
"Doping": "Alleged drug use during 1997 due to high hermatocrit levels",
"URL": "https://en.wikipedia.org/wiki/Marco_Pantani#Alleged_drug_use"
},
In this codepen I am trying to create a column chart with scaleBand for the x and width. But, I have a large gap between the second and third columns. Why is it doing that? The full D3 code is below but the codepen also has the data that I am using for the column chart. Thank you.
data.forEach(function(d) {
d.height = +d.height;
});
var w = 400;
var h = 400;
var xScale = d3.scaleBand()
.domain(["Burj Khalifa", "hanghai Tower", "Abraj Al-Bait Clock Tower",
"Ping An Finance Centre", "Lotte World Tower", "One World Trade Center",
"Guangzhou CTF Finance Center"])
.range([0, 400])
.paddingInner(0.3)
.paddingOuter(0.3);
var yScale = d3.scaleLinear()
.domain([0,828])
.range([0,400]);
var svg = d3.select("#chart-area").append("svg")
.attr("width", w)
.attr("height", h);
var rects = svg.selectAll("rect")
.data(data);
rects.enter().append("rect")
.attr("width", xScale.bandwidth)
.attr("height", (d) => yScale(d.height))
.attr("x", (d) => xScale(d.name))
.attr("y", (d) => h - yScale(d.height))
.attr("fill", "blue");
It seems your domain array was not mapped correctly. Rather use your xScale using computed arrays which will avoid any spelling mistakes / special character interpretation.
Using this solves it.
var xScale = d3.scaleBand()
.domain(data.map(d => d.name))
.range([0, 400])
.paddingInner(0.3)
.paddingOuter(0.3);
Here is the working codepen.
What I'm trying to do is create a bar chart using D3.js (v4) that will show 2 or 3 bars that have a big difference in values.
Like shown on the picture below yellow bar has a value 1596.6, whereas the green bar has only 177.2. So in order to show the charts in elegant way it was decided to cut the y axis at a certain value which would be close to green bar's value and continue closer to yellow bar's value.
On the picture the y axis is cut after 500 and continues after 1500.
How one would do that using D3.js?
What you want is, technically speaking, a broken y axis.
It's a valid representation in data visualization (despite being frowned upon by some people), as long as you explicitly tell the user that your y axis is broken. There are several ways to visually indicate it, like these:
However, don't forget to make this clear in the text of the chart or in its legend. Besides that, have in mind that "in order to show the charts in elegant way" should never be the goal here: we don't make charts to be elegant (but it's good if they are), we make charts to clarify an information or a pattern to the user. And, in some very limited situations, breaking the y axis can better show the information.
Back to your question:
Breaking the y axis is useful when you have, like you said in your question, big differences in values. For instance, have a look at this simple demo I made:
var w = 500,
h = 220,
marginLeft = 30,
marginBottom = 30;
var svg = d3.select("body")
.append("svg")
.attr("width", w)
.attr("height", h);
var data = [{
name: "foo",
value: 800
}, {
name: "bar",
value: 720
}, {
name: "baz",
value: 10
}, {
name: "foobar",
value: 17
}, {
name: "foobaz",
value: 840
}];
var xScale = d3.scaleBand()
.domain(data.map(function(d) {
return d.name
}))
.range([marginLeft, w])
.padding(.5);
var yScale = d3.scaleLinear()
.domain([0, d3.max(data, function(d) {
return d.value
})])
.range([h - marginBottom, 0]);
var bars = svg.selectAll(null)
.data(data)
.enter()
.append("rect")
.attr("x", function(d) {
return xScale(d.name)
})
.attr("width", xScale.bandwidth())
.attr("y", function(d) {
return yScale(d.value)
})
.attr("height", function(d) {
return h - marginBottom - yScale(d.value)
})
.style("fill", "teal");
var xAxis = d3.axisBottom(xScale)(svg.append("g").attr("transform", "translate(0," + (h - marginBottom) + ")"));
var yAxis = d3.axisLeft(yScale)(svg.append("g").attr("transform", "translate(" + marginLeft + ",0)"));
<script src="https://d3js.org/d3.v4.js"></script>
As you can see, the baz and foobar bars are dwarfed by the other bars, since their differences are very big.
The simplest solution, in my opinion, is adding midpoints in the range and domain of the y scale. So, in the above snippet, this is the y scale:
var yScale = d3.scaleLinear()
.domain([0, d3.max(data, function(d) {
return d.value
})])
.range([h - marginBottom, 0]);
If we add midpoints to it, it become something like:
var yScale = d3.scaleLinear()
.domain([0, 20, 700, d3.max(data, function(d) {
return d.value
})])
.range([h - marginBottom, h/2 + 2, h/2 - 2, 0]);
As you can see, there are some magic numbers here. Change them according to your needs.
By inserting the midpoints, this is the correlation between domain and range:
+--------+-------------+---------------+---------------+------------+
| | First value | Second value | Third value | Last value |
+--------+-------------+---------------+---------------+------------+
| Domain | 0 | 20 | 700 | 840 |
| | maps to... | maps to... | maps to... | maps to... |
| Range | height | heigh / 2 - 2 | heigh / 2 + 2 | 0 |
+--------+-------------+---------------+---------------+------------+
You can see that the correlation is not proportional, and that's exactly what we want.
And this is the result:
var w = 500,
h = 220,
marginLeft = 30,
marginBottom = 30;
var svg = d3.select("body")
.append("svg")
.attr("width", w)
.attr("height", h);
var data = [{
name: "foo",
value: 800
}, {
name: "bar",
value: 720
}, {
name: "baz",
value: 10
}, {
name: "foobar",
value: 17
}, {
name: "foobaz",
value: 840
}];
var xScale = d3.scaleBand()
.domain(data.map(function(d) {
return d.name
}))
.range([marginLeft, w])
.padding(.5);
var yScale = d3.scaleLinear()
.domain([0, 20, 700, d3.max(data, function(d) {
return d.value
})])
.range([h - marginBottom, h/2 + 2, h/2 - 2, 0]);
var bars = svg.selectAll(null)
.data(data)
.enter()
.append("rect")
.attr("x", function(d) {
return xScale(d.name)
})
.attr("width", xScale.bandwidth())
.attr("y", function(d) {
return yScale(d.value)
})
.attr("height", function(d) {
return h - marginBottom - yScale(d.value)
})
.style("fill", "teal");
var xAxis = d3.axisBottom(xScale)(svg.append("g").attr("transform", "translate(0," + (h - marginBottom) + ")"));
var yAxis = d3.axisLeft(yScale).tickValues([0, 5, 10, 15, 700, 750, 800])(svg.append("g").attr("transform", "translate(" + marginLeft + ",0)"));
svg.append("rect")
.attr("x", marginLeft - 10)
.attr("y", yScale(19.5))
.attr("height", 10)
.attr("width", 20);
svg.append("rect")
.attr("x", marginLeft - 10)
.attr("y", yScale(19))
.attr("height", 6)
.attr("width", 20)
.style("fill", "white");
<script src="https://d3js.org/d3.v4.js"></script>
As you can see, I had to set the y axis ticks using tickValues, otherwise it'd be a mess.
Finally, I can't stress this enough: do not forget to show that the y axis is broken. In the snippet above, I put the symbol (see the first figure in my answer) between the number 15 and the number 700 in the y axis. By the way, it's worth noting that in the figure you shared its author didn't put any symbol in the y axis, which is not a good practice.