Reputation: 8559
I'm on d3.js v.7.
I need to build a linear gradient with hard stops (i.e., color 0 from 0 to x, color 1 from x to y, ... color n from z to 1). stop
list has an arbitrary length and it is defined by a matrix like the following:
const matrix = [
[{start: 0, stop:0.5, color: 'blue'}, {start: 0.5, stop:1, color: 'teal'}],
[{start: 0, stop:0.2, color: 'blue'}, {start: 0.2, stop:1, color: 'teal'}]
];
that should be rendered as follows:
<defs>
<lineargradient id="grad_0">
<!--stop 0 -->
<stop offset="0" stop-color="blue"></stop>
<stop offset="0.5" stop-color="blue"></stop>
<!--stop 1 -->
<stop offset="0.5" stop-color="teal"></stop>
<stop offset="1" stop-color="teal"></stop>
</lineargradient>
<lineargradient id="grad_1">
<!--stop 0 -->
<stop offset="0" stop-color="blue"></stop>
<stop offset="0.2" stop-color="blue"></stop>
<!--stop 1 -->
<stop offset="0.2" stop-color="teal"></stop>
<stop offset="1" stop-color="teal"></stop>
</lineargradient>
</defs>
NB: look at the stop-color
elements: stop
s follow the matrix nesting
Starting from Mike Bostock's Nested Selections, I tried with this solution:
const defs = d3.select('body').append('defs');
const linearGradients = defs
.selectAll('linearGradient')
.data(matrix)
.join('linearGradient')
.attr('id', (d, i) => `grad_${i}`);
linearGradients
.selectAll('stop')
.data(d => d) //matrix[j]
.join((enter) => {
enter
.append('stop')
.attr('offset', (d) => d.start)
.attr('stop-color', (d) => d.color);
enter
.append('stop')
.attr('offset', (d) => d.stop)
.attr('stop-color', (d) => d.color);
});
However, stop
s do not follow the matrix nesting and they are not intercalated. This is what I get with the code above:
<defs>
<lineargradient id="grad_0">
<stop offset="0" stop-color="blue"></stop>
<stop offset="0.5" stop-color="teal"></stop>
<stop offset="0.5" stop-color="blue"></stop>
<stop offset="1" stop-color="teal"></stop>
</lineargradient>
<lineargradient id="grad_1">
<stop offset="0" stop-color="blue"></stop>
<stop offset="0.2" stop-color="teal"></stop>
<stop offset="0.2" stop-color="blue"></stop>
<stop offset="1" stop-color="teal"></stop>
</lineargradient>
</defs>
...and it's wrong!
I have already checked out answers like this one, but they do not work anymore with newer versions of d3.
Here is a working example to play with: https://codepen.io/floatingpurr/pen/RwpvvPq
Is there any idiomatic and working solution?
Upvotes: 2
Views: 230
Reputation: 38151
As noted in the links: D3 generally expects one item in a data array to one element in the DOM. This is part of the foundation of its data binding method. This approach still allows:
We can't do one because our nested data doesn't contain an array of all the elements we wish to enter.
We also can't do two because we don't have a one to one relationship between parent and child - we have nested data after all.
Sometimes you can cheat and reuse the enter selection to double elements or use classes to enter elements many times, but these approaches are predicated on indifference to the order of the elements in the DOM.
But, when order matters, and we need multiple elements in the DOM for each item in the data array, we need a different approach. As noted in the comments, an equivalent problem is update/exiting/entering pairs of <dd>
and <dt>
elements since strictly speaking a described list doesn't allow any parent that could group these pairs for ordering and organization. Same as <stop>
elements in a <linearGradient>
, as is your case.
There are four classes of solution that come to mind:
Modify the data so one element corresponds to one item in the data array.
The first is probably the most straightforward in that it would be the most canonical and require little consideration about how to modify the D3 code. Below I use a function to take your input data and create one data array item for each stop - essentially cracking each existing item into two. To do so we should probably use common properties, so instead of start
and end
properties we'll just have offset
values in all:
const matrix = [
[{start: 0, stop:0.5, color: 'blue'}, {start: 0.5, stop:1, color: 'teal'}],
[{start: 0, stop:0.2, color: 'blue'}, {start: 0.2, stop:1, color: 'teal'}]
];
// produce new data array:
function processData(data) {
let newData = data.map(function(gradient) {
let stops = [];
gradient.forEach(function(stop) {
stops.push({offset: stop.start, color: stop.color})
stops.push({offset: stop.stop, color: stop.color})
})
return stops;
})
return newData;
}
console.log(processData(matrix));
Here it is in work below with some random data that is constantly updated:
// produce new data array:
function processData(data) {
let newData = data.map(function(gradient) {
let stops = [];
gradient.forEach(function(stop) {
stops.push({offset: stop.start, color: stop.color})
stops.push({offset: stop.stop, color: stop.color})
})
return stops;
})
return newData;
}
//Basic set up:
var defs = d3.select("defs");
var svg = d3.select("svg");
update();
setInterval(update, 1000);
// Update with new data:
function update() {
var data = randomGradients();
data = processData(data);
// update gradients:
var grads = defs.selectAll("linearGradient")
.data(data)
.join("linearGradient")
.attr("id",(d,i)=>"grad_"+i);
var pairs = grads.selectAll("stop")
.data(d=>d)
.join("stop")
.transition()
.attr("stop-color",d=>d.color)
.attr("offset",d=>d.offset);
// update rectangles:
svg.selectAll("rect")
.data(grads.data())
.join("rect")
.attr("y", (d,i)=>i*60+5)
.attr("x", 10)
.attr("width", 360)
.attr("height", 50)
.attr("fill",(d,i)=>"url(#grad_"+i+")");
}
// Random Data Generator Functions:
function createStop(a,b,c) {
return {start: a, stop: b, color: c};
}
function randomData(n) {
let colorSchemes = [
d3.schemeBlues,
d3.schemeReds,
d3.schemePurples,
d3.schemeGreens,
d3.schemeGreys,
d3.schemeOranges
];
let points = d3.range(n-1).map(function(i) {
return Math.random()
}).sort()
points.push(1);
let stops = [];
let start = 0;
let end = 1;
let r = Math.floor(Math.random()*(colorSchemes.length));
let colors = colorSchemes[r][n];
for (let i = 0; i < n; i++) {
stops.push(createStop(start,points[i],colors[i]));
start = points[i];
}
return stops;
}
function randomGradients() {
let gradients = [];
let n = Math.floor(Math.random()*5)+1
let m = Math.floor(Math.random()*3)+3;
for(var i = 0; i < n; i++) {
gradients.push(randomData(m));
}
return gradients;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<svg width="500" height="400">
<defs>
</defs>
<rect width="300" height="50" fill="url(#grad_0)">
</rect>
</svg>
This works when we have the same type of element to append for each item in the data array (stop
) elements, but we'd need to be more creative if we had different types of items as in the dd/dt
case. append()
does allow variable tags (fourth approach listed in linked answer) to be appended, but things will get a bit more complicated when updating.
Abuse d3-selection
I add this for completeness and because it is an interesting case, you should in most circumstances change the data structure as in the option above, though extending D3 can at times be the clearly preferred option
You could do this in a number of ways. I've decided to create a new d3-selection method, but this isn't necessary. This approach requires a bit more care and may have some possible unintentional consequences (I think I've done well in avoiding most) - but preserves your data structure. I've created a method selection.joinPair()
which takes a single parameter: the type of element we'd like to have two of for each item in the data array. It returns a selection of all the elements, but also has two methods: first()
and second()
which return the first or second set of elements respectively. It assumes the parent's datum is the data array for the child elements as is our case here. (I may revise the snippet below later to make it a bit to be a bit more flexible, but it is provided more as a demonstration rather than as a final product or potential d3 module.
// New d3 method to join pairs:
d3.selection.prototype.joinPairs = function(type) {
let parents = this; // selection this is called on.
// select every even, odd element in two selections, set data
let a = parents.selectAll(type+":nth-child(2n+1)")
.data((d,i)=>parents.data()[i]);
let b = parents.selectAll(type+":nth-child(2n+2)")
.data((d,i)=>parents.data()[i]);
// remove unneeded children:
a.exit().remove();
b.exit().remove();
// enter, as we enter in pairs, we can use selection.clone()
// which enters a new element immediately after the cloned node in the DOM.
enterA = a.enter().append(type);
enterB = enterA.clone();
// return the selection of all elements, but allow access to odds/evens separately:
let sel = parents.selectAll(type);
sel.first=()=>a.merge(enterA);
sel.second=()=>b.merge(enterB);
return sel;
}
And here we have at work:
// New d3 method to join pairs:
d3.selection.prototype.joinPairs = function(type) {
let parents = this;
let a = parents.selectAll(type+":nth-child(2n+1)")
.data((d,i)=>parents.data()[i]);
let b = parents.selectAll(type+":nth-child(2n+2)")
.data((d,i)=>parents.data()[i]);
a.exit().remove();
b.exit().remove();
enterA = a.enter().append(type);
enterB = enterA.clone();
let sel = parents.selectAll(type);
sel.first=()=>a.merge(enterA);
sel.second=()=>b.merge(enterB);
return sel;
}
//Basic set up:
var defs = d3.select("defs");
var svg = d3.select("svg");
update();
setInterval(update, 1000);
// Update with new data:
function update() {
// update gradients:
var grads = defs.selectAll("linearGradient")
.data(randomGradients())
.join("linearGradient")
.attr("id",(d,i)=>"grad_"+i);
var pairs = grads.joinPairs("stop")
.attr("stop-color",d=>d.color)
// set first element in pair's offset:
pairs.first().transition()
.attr("offset",d=>d.start)
// set second element in pair's offset:
pairs.second().transition()
.attr("offset",d=>d.stop)
// update rectangles:
svg.selectAll("rect")
.data(grads.data())
.join("rect")
.attr("y", (d,i)=>i*60+5)
.attr("x", 10)
.attr("width", 360)
.attr("height", 50)
.attr("fill",(d,i)=>"url(#grad_"+i+")");
}
// Random Data Generator Functions:
function createStop(a,b,c) {
return {start: a, stop: b, color: c};
}
function randomData(n) {
let colorSchemes = [
d3.schemeBlues,
d3.schemeReds,
d3.schemePurples,
d3.schemeGreens,
d3.schemeGreys,
d3.schemeOranges
];
let points = d3.range(n-1).map(function(i) {
return Math.random()
}).sort()
points.push(1);
let stops = [];
let start = 0;
let end = 1;
let r = Math.floor(Math.random()*(colorSchemes.length));
let colors = colorSchemes[r][n];
for (let i = 0; i < n; i++) {
stops.push(createStop(start,points[i],colors[i]));
start = points[i];
}
return stops;
}
function randomGradients() {
let gradients = [];
let n = Math.floor(Math.random()*5)+1
let m = Math.floor(Math.random()*3)+3;
for(var i = 0; i < n; i++) {
gradients.push(randomData(m));
}
return gradients;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<svg width="500" height="400">
<defs>
</defs>
<rect width="300" height="50" fill="url(#grad_0)">
</rect>
</svg>
This way is way less portable though, unless further developed.
Get creative in sorting
This way fits within standard D3 methods, but isn't the most typical approach. It might get messy in certain circumstances, but in this use case is rather clean - though with many items might be slower than others.
We'll enter/update/exit data based on classes, and sort data based on a property of the element. However, it is a bit challenging in that we can't use the datum to store an index as the datum is shared between elements. Luckily, as we append the "start" stop first and the "stop" stop second sorting by d.start will work as long as we append all the "starts" before all the "stops":
//Basic set up:
var defs = d3.select("defs");
var svg = d3.select("svg");
update();
setInterval(update, 1000);
// Update with new data:
function update() {
// update gradients, same as before:
var grads = defs.selectAll("linearGradient")
.data(randomGradients())
.join("linearGradient")
.attr("id",(d,i)=>"grad_"+i);
// starts:
grads.selectAll(".start")
.data(d=>d)
.join("stop")
.transition()
.attr("stop-color",d=>d.color)
.attr("offset",d=>d.start)
.attr("class","start");
// ends:
grads.selectAll(".end")
.data(d=>d)
.join("stop")
.transition()
.attr("stop-color",d=>d.color)
.attr("offset",d=>d.stop)
.attr("class","end")
// sort based on start value (where they are the same, first appended item is first):
grads.each(function() {
d3.select(this).selectAll("stop")
.sort(function(a,b) { return a.start-b.start })
})
// update rectangles:
svg.selectAll("rect")
.data(grads.data())
.join("rect")
.attr("y", (d,i)=>i*60+5)
.attr("x", 10)
.attr("width", 360)
.attr("height", 50)
.attr("fill",(d,i)=>"url(#grad_"+i+")");
}
// Random Data Generator Functions:
function createStop(a,b,c) {
return {start: a, stop: b, color: c};
}
function randomData(n) {
let colorSchemes = [
d3.schemeBlues,
d3.schemeReds,
d3.schemePurples,
d3.schemeGreens,
d3.schemeGreys,
d3.schemeOranges
];
let points = d3.range(n-1).map(function(i) {
return Math.random()
}).sort()
points.push(1);
let stops = [];
let start = 0;
let end = 1;
let r = Math.floor(Math.random()*(colorSchemes.length));
let colors = colorSchemes[r][n];
for (let i = 0; i < n; i++) {
stops.push(createStop(start,points[i],colors[i]));
start = points[i];
}
return stops;
}
function randomGradients() {
let gradients = [];
let n = Math.floor(Math.random()*5)+1
let m = Math.floor(Math.random()*3)+3;
for(var i = 0; i < n; i++) {
gradients.push(randomData(m));
}
return gradients;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<svg width="500" height="400">
<defs>
</defs>
<rect width="300" height="50" fill="url(#grad_0)">
</rect>
</svg>
I'm not going to go into depth on this option, it should be relatively straightforward as is.
Use selection.html() to manually add the child elements
This is really outside the D3 idiom, but at times such an approach may be justifiable. We'll join the linearGradient
s as normal, then use selection.html to create the child stops. I include it only for completeness, it is shown below:
This is even transitionable! (barely)
//Basic set up:
var defs = d3.select("defs");
var svg = d3.select("svg");
update();
setInterval(update,1000);
// Update with new data:
function update() {
// update gradients:
var grads = defs.selectAll("linearGradient")
.data(randomGradients())
.join("linearGradient")
.attr("id",(d,i)=>"grad_"+i);
grads.transition().attrTween("anything", function(d) {
var el = this;
var end = "";
d.forEach(function(s) {
end += '<stop offset="'+s.start+'" stop-color="'+d3.color(s.color).formatRgb()+'"></stop>';
end += '<stop offset="'+s.stop+'" stop-color="'+d3.color(s.color).formatRgb()+'"></stop>';
})
var start = d3.select(this).html() || end;
function interpolator(t) {
var i = d3.interpolate(start,end)(t);
d3.select(el).html(i);
return 1;
}
return interpolator
});
// update rectangles:
svg.selectAll("rect")
.data(grads.data())
.join("rect")
.attr("y", (d,i)=>i*60+5)
.attr("x", 10)
.attr("width", 360)
.attr("height", 50)
.attr("fill",(d,i)=>"url(#grad_"+i+")");
}
// Random Data Generator Functions:
function createStop(a,b,c) {
return {start: a, stop: b, color: c};
}
function randomData(n) {
let colorSchemes = [
d3.schemeBlues,
d3.schemeReds,
d3.schemePurples,
d3.schemeGreens,
d3.schemeGreys,
d3.schemeOranges
];
let points = d3.range(n-1).map(function(i) {
return Math.random()
}).sort()
points.push(1);
let stops = [];
let start = 0;
let end = 1;
let r = Math.floor(Math.random()*(colorSchemes.length));
let colors = colorSchemes[r][n];
for (let i = 0; i < n; i++) {
stops.push(createStop(start,points[i],colors[i]));
start = points[i];
}
return stops;
}
function randomGradients() {
let gradients = [];
let n = Math.floor(Math.random()*5)+1
let m = Math.floor(Math.random()*3)+3;
for(var i = 0; i < n; i++) {
gradients.push(randomData(m));
}
return gradients;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<svg width="500" height="400">
<defs>
</defs>
<rect width="300" height="50" fill="url(#grad_0)">
</rect>
</svg>
Options not included
I haven't included any options where you can't easily transition from old data to new data, so anything that uses selection.remove() to wipe the slate, or part of the slate clean in order to build from scratch is not included here.
Upvotes: 1